From 403a942b55bc65cdb4f5b23c0d89d8dfa82f11b9 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Wed, 11 Dec 2024 10:11:21 +0100 Subject: [PATCH 01/29] update readium annotation set model [skip ci] --- .../annotation/annotationModel.type.ts | 159 +++++++++--------- src/common/readium/annotation/converter.ts | 44 +---- .../actions/annotation/importTriggerModal.ts | 4 +- src/common/redux/states/importAnnotation.ts | 4 +- src/main/redux/sagas/annotation.ts | 4 +- src/renderer/reader/components/ReaderMenu.tsx | 4 +- 6 files changed, 92 insertions(+), 127 deletions(-) diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts index e881e1769..9d680451e 100644 --- a/src/common/readium/annotation/annotationModel.type.ts +++ b/src/common/readium/annotation/annotationModel.type.ts @@ -8,7 +8,7 @@ import Ajv from "ajv"; import addFormats from "ajv-formats"; -export interface IReadiumAnnotationModel { +export interface IReadiumAnnotation { "@context": "http://www.w3.org/ns/anno.jsonld"; id: string; created: string; @@ -40,13 +40,30 @@ export interface IReadiumAnnotationModel { }; selector: Array<( ITextQuoteSelector - | IProgressionSelector - | IDomRangeSelector + | ITextPositionSelector | IFragmentSelector )>; }; } +/** +{ + "type": "TextPositionSelector", + "start": 50, + "end": 55 +} +*/ +export interface ITextPositionSelector { + 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 { type: "TextQuoteSelector"; exact: string; @@ -60,34 +77,39 @@ export function isTextQuoteSelector(a: any): a is ITextQuoteSelector { && typeof a.suffix === "string"; } -export interface IProgressionSelector { - type: "ProgressionSelector"; - value: number; -} -export function isProgressionSelector(a: any): a is IProgressionSelector { - return typeof a === "object" && a.type === "ProgressionSelector" - && typeof a.value === "number"; -} +// not used anymore +// not an official w3c annotation selector +// export interface IProgressionSelector { +// type: "ProgressionSelector"; +// value: number; +// } +// export function isProgressionSelector(a: any): a is IProgressionSelector { +// return typeof a === "object" && a.type === "ProgressionSelector" +// && typeof a.value === "number"; +// } -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"; -} +// 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 { type: "FragmentSelector"; @@ -101,6 +123,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,7 +147,7 @@ interface About { "dc:date"?: string; } -export interface IReadiumAnnotationModelSet { +export interface IReadiumAnnotationSet { "@context": "http://www.w3.org/ns/anno.jsonld"; id: string; type: "AnnotationSet"; @@ -125,12 +155,12 @@ export interface IReadiumAnnotationModelSet { generated?: string; title?: string; about: About; - items: IReadiumAnnotationModel[]; + items: IReadiumAnnotation[]; } -export const readiumAnnotationModelSetJSONSchema3 = { +export const readiumAnnotationSetSchema = { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "IReadiumAnnotationModelSet", + "title": "IReadiumAnnotationSet", "type": "object", "properties": { "@context": { @@ -214,15 +244,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": { @@ -343,10 +373,7 @@ export const readiumAnnotationModelSetJSONSchema3 = { "$ref": "#/definitions/ITextQuoteSelector", }, { - "$ref": "#/definitions/IProgressionSelector", - }, - { - "$ref": "#/definitions/IDomRangeSelector", + "$ref": "#/definitions/ITextPositionSelector", }, { "$ref": "#/definitions/IFragmentSelector", @@ -378,52 +405,20 @@ export const readiumAnnotationModelSetJSONSchema3 = { }, "required": ["type", "exact", "prefix", "suffix"], }, - "IProgressionSelector": { - "type": "object", - "properties": { - "type": { - "const": "ProgressionSelector", - }, - "value": { - "type": "number", - }, - }, - "required": ["type", "value"], - }, - "IDomRangeSelector": { + "ITextPositionSelector": { "type": "object", "properties": { "type": { - "const": "DomRangeSelector", - }, - "startContainerElementCssSelector": { - "type": "string", - }, - "startContainerChildTextNodeIndex": { - "type": "number", - }, - "startOffset": { - "type": "number", - }, - "endContainerElementCssSelector": { - "type": "string", - }, - "endContainerChildTextNodeIndex": { - "type": "number", + "const": "TextPositionSelector", }, - "endOffset": { + "start": { "type": "number", }, + "end": { + "type": "number" + } }, - "required": [ - "type", - "startContainerElementCssSelector", - "startContainerChildTextNodeIndex", - "startOffset", - "endContainerElementCssSelector", - "endContainerChildTextNodeIndex", - "endOffset", - ], + "required": ["type", "start", "end"], }, "IFragmentSelector": { "type": "object", @@ -445,12 +440,12 @@ export const readiumAnnotationModelSetJSONSchema3 = { 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..ad735e57e 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -5,25 +5,25 @@ // 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 { IReadiumAnnotation, IReadiumAnnotationSet } 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"; -export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotationState): IReadiumAnnotationModel { +export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotationState): IReadiumAnnotation { 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 { rangeInfo: rangeInfoSelection } = selectionInfo || {}; + // const { progression } = locations; - const highlight: IReadiumAnnotationModel["body"]["highlight"] = drawType === "solid_background" ? "solid" : drawType; + const highlight: IReadiumAnnotation["body"]["highlight"] = drawType === "solid_background" ? "solid" : drawType; - const selector: IReadiumAnnotationModel["target"]["selector"] = []; + const selector: IReadiumAnnotation["target"]["selector"] = []; if (highlightRaw && afterRaw && beforeRaw) { selector.push({ @@ -33,36 +33,6 @@ export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotatio suffix: afterRaw, }); } - if (progression) { - selector.push({ - type: "ProgressionSelector", - value: progression, - }); - } - 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 (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 - }); - } return { "@context": "http://www.w3.org/ns/anno.jsonld", @@ -92,7 +62,7 @@ export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotatio }; } -export function convertAnnotationListToReadiumAnnotationSet(annotationArray: IAnnotationState[], publicationView: PublicationView, label?: string): IReadiumAnnotationModelSet { +export function convertAnnotationListToReadiumAnnotationSet(annotationArray: IAnnotationState[], publicationView: PublicationView, label?: string): IReadiumAnnotationSet { const currentDate = new Date(); const dateString: string = currentDate.toISOString(); diff --git a/src/common/redux/actions/annotation/importTriggerModal.ts b/src/common/redux/actions/annotation/importTriggerModal.ts index 4d15d280a..ff4c37dc8 100644 --- a/src/common/redux/actions/annotation/importTriggerModal.ts +++ b/src/common/redux/actions/annotation/importTriggerModal.ts @@ -6,12 +6,12 @@ // ==LICENSE-END== import { Action } from "readium-desktop/common/models/redux"; -import { IReadiumAnnotationModelSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; import { IAnnotationState } 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 { diff --git a/src/common/redux/states/importAnnotation.ts b/src/common/redux/states/importAnnotation.ts index cf3f300e4..3335e9f8f 100644 --- a/src/common/redux/states/importAnnotation.ts +++ b/src/common/redux/states/importAnnotation.ts @@ -5,10 +5,10 @@ // 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 { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; -interface IReadiumAnnotationModelSetView extends Partial> { +interface IReadiumAnnotationModelSetView extends Partial> { } export interface IImportAnnotationState extends IReadiumAnnotationModelSetView { diff --git a/src/main/redux/sagas/annotation.ts b/src/main/redux/sagas/annotation.ts index f278af0af..660fe9f6e 100644 --- a/src/main/redux/sagas/annotation.ts +++ b/src/main/redux/sagas/annotation.ts @@ -18,7 +18,7 @@ import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/a 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 { __READIUM_ANNOTATION_AJV_ERRORS, isDomRangeSelector, isFragmentSelector, isIReadiumAnnotationSet, 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"; @@ -82,7 +82,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct debug("filePath size=", data.length); debug("filePath serialized and ready to pass the type checker"); - if (isIReadiumAnnotationModelSet(readiumAnnotationFormat)) { + if (isIReadiumAnnotationSet(readiumAnnotationFormat)) { debug("filePath pass the typeChecker (ReadiumAnnotationModelSet)"); diff --git a/src/renderer/reader/components/ReaderMenu.tsx b/src/renderer/reader/components/ReaderMenu.tsx index f7398a8c4..82abaf831 100644 --- a/src/renderer/reader/components/ReaderMenu.tsx +++ b/src/renderer/reader/components/ReaderMenu.tsx @@ -90,7 +90,7 @@ import { useReaderConfig, useSaveReaderConfig } from "readium-desktop/renderer/c 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 { IReadiumAnnotationSet } 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"; @@ -667,7 +667,7 @@ const AnnotationCard: React.FC<{ timestamp: number, annotation: IAnnotationState const selectionIsSet = (a: Selection): a is Set => typeof a === "object"; const MAX_MATCHES_PER_PAGE = 5; -const downloadAnnotationJSON = (contents: IReadiumAnnotationModelSet, filename: string) => { +const downloadAnnotationJSON = (contents: IReadiumAnnotationSet, filename: string) => { const data = JSON.stringify(contents, null, 2); const blob = new Blob([data], { type: "application/rd-annotations+json" }); From bf63fe5908b6522a897b4aac3a86803cb655fc0a Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Thu, 12 Dec 2024 12:16:56 +0100 Subject: [PATCH 02/29] annotation import FIFO queue from main to reader process --- .../annotation/annotationModel.type.ts | 4 +- src/common/readium/annotation/converter.ts | 4 +- .../actions/annotation/importTriggerModal.ts | 8 +- src/common/redux/actions/annotation/index.ts | 4 + .../annotation/pushToAnnotationImportQueue.ts | 25 + .../shiftFromAnnotationImportQueue.ts | 22 + src/common/redux/states/commonRootState.ts | 3 + src/common/redux/states/importAnnotation.ts | 8 +- .../redux/states/renderer/annotation.ts | 3 + src/main/redux/middleware/persistence.ts | 2 + src/main/redux/middleware/sync.ts | 6 + src/main/redux/reducers/index.ts | 20 +- src/main/redux/sagas/annotation.ts | 499 +++++++----------- src/main/redux/sagas/persist.ts | 1 + src/main/redux/sagas/win/reader.ts | 2 + src/main/redux/states/index.ts | 2 +- src/renderer/reader/redux/middleware/sync.ts | 2 + src/renderer/reader/redux/reducers/index.ts | 20 +- src/utils/redux-reducers/fifo.reducer.ts | 85 +++ 19 files changed, 404 insertions(+), 316 deletions(-) create mode 100644 src/common/redux/actions/annotation/pushToAnnotationImportQueue.ts create mode 100644 src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts create mode 100644 src/utils/redux-reducers/fifo.reducer.ts diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts index 9d680451e..d64df9a5c 100644 --- a/src/common/readium/annotation/annotationModel.type.ts +++ b/src/common/readium/annotation/annotationModel.type.ts @@ -415,8 +415,8 @@ export const readiumAnnotationSetSchema = { "type": "number", }, "end": { - "type": "number" - } + "type": "number", + }, }, "required": ["type", "start", "end"], }, diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index ad735e57e..6cdfa0b0e 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -15,8 +15,8 @@ import { rgbToHex } from "readium-desktop/common/rgb"; export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotationState): IReadiumAnnotation { const { uuid, color, locatorExtended: def, tags, drawType, comment, creator, created, modified } = annotation; - const { locator, headings, epubPage, selectionInfo } = def; - const { href, text, locations } = locator; + const { locator, headings, epubPage/*, selectionInfo*/ } = def; + const { href, text/*, locations*/ } = locator; const { afterRaw, beforeRaw, highlightRaw } = text || {}; // const { rangeInfo: rangeInfoSelection } = selectionInfo || {}; // const { progression } = locations; diff --git a/src/common/redux/actions/annotation/importTriggerModal.ts b/src/common/redux/actions/annotation/importTriggerModal.ts index ff4c37dc8..ab773634c 100644 --- a/src/common/redux/actions/annotation/importTriggerModal.ts +++ b/src/common/redux/actions/annotation/importTriggerModal.ts @@ -7,7 +7,7 @@ import { Action } from "readium-desktop/common/models/redux"; import { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; -import { IAnnotationState } from "../../states/renderer/annotation"; +import { IAnnotationPreParsingState } from "../../states/renderer/annotation"; export const ID = "ANNOTATION_IMPORT_TRIGGER_MODAL"; @@ -15,9 +15,9 @@ export interface IReadiumAnnotationModelSetView extends Partial { 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/common/redux/actions/annotation/pushToAnnotationImportQueue.ts b/src/common/redux/actions/annotation/pushToAnnotationImportQueue.ts new file mode 100644 index 000000000..53efc1188 --- /dev/null +++ b/src/common/redux/actions/annotation/pushToAnnotationImportQueue.ts @@ -0,0 +1,25 @@ +// ==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 } from "readium-desktop/common/models/redux"; +import { IAnnotationPreParsingState } from "../../states/renderer/annotation"; + +export const ID = "ANNOTATION_PUSH_TO_ANNOTATION_IMPORT_QUEUE"; + +export interface Payload { + annotations: IAnnotationPreParsingState[]; +} +export function build(annotations: IAnnotationPreParsingState[]): Action { + return { + type: ID, + payload: { + annotations, + }, + }; +} +build.toString = () => ID; // Redux StringableActionCreator +export type TAction = ReturnType; diff --git a/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts b/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts new file mode 100644 index 000000000..61df62e15 --- /dev/null +++ b/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts @@ -0,0 +1,22 @@ +// ==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 } from "readium-desktop/common/models/redux"; + +export const ID = "ANNOTATION_SHIFT_FROM_ANNOTATION_IMPORT_QUEUE"; + +export interface Payload { +} +export function build(): Action { + return { + type: ID, + payload: { + }, + }; +} +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 3335e9f8f..3cfb085ca 100644 --- a/src/common/redux/states/importAnnotation.ts +++ b/src/common/redux/states/importAnnotation.ts @@ -6,15 +6,15 @@ // ==LICENSE-END== import { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; -import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +import { IAnnotationPreParsingState } from "readium-desktop/common/redux/states/renderer/annotation"; 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/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 2ee2f30b6..cd0dea690 100644 --- a/src/main/redux/middleware/sync.ts +++ b/src/main/redux/middleware/sync.ts @@ -87,6 +87,12 @@ const SYNCHRONIZABLE_ACTIONS: string[] = [ annotationActions.importTriggerModal.ID, // annotationActions.importConfirmOrAbort.ID, + annotationActions.pushToAnnotationImportQueue.ID, + + + // TODO: shift dispatch from one reader do not dispatch it to other reader !?! need to check this issue before merge request + annotationActions.shiftFromAnnotationImportQueue.ID, + ]; export const reduxSyncMiddleware: Middleware 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/sagas/annotation.ts b/src/main/redux/sagas/annotation.ts index 660fe9f6e..5e7610fe1 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, isIReadiumAnnotationSet, 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,243 @@ 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 (isIReadiumAnnotationSet(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 { + 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 +319,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 d316bb2fb..0af68a2b3 100644 --- a/src/main/redux/sagas/win/reader.ts +++ b/src/main/redux/sagas/win/reader.ts @@ -44,6 +44,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[] = []; @@ -90,6 +91,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/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 182efbc63..f3b313577 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,7 @@ 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"; export const rootReducer = () => { @@ -194,5 +195,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/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; +} From 0429d0848b2955091c8a9c1ef2c3689c2c5ea9ac Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Thu, 12 Dec 2024 15:14:06 +0100 Subject: [PATCH 03/29] update json schema selector --- .../annotation/annotationModel.type.ts | 107 ++++++++++-------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts index d64df9a5c..5b42318aa 100644 --- a/src/common/readium/annotation/annotationModel.type.ts +++ b/src/common/readium/annotation/annotationModel.type.ts @@ -370,14 +370,14 @@ export const readiumAnnotationSetSchema = { "items": { "oneOf": [ { - "$ref": "#/definitions/ITextQuoteSelector", - }, - { - "$ref": "#/definitions/ITextPositionSelector", - }, - { - "$ref": "#/definitions/IFragmentSelector", + "$ref": "#/definitions/Selector", }, + // { + // "$ref": "#/definitions/ITextPositionSelector", + // }, + // { + // "$ref": "#/definitions/IFragmentSelector", + // }, ], }, }, @@ -387,54 +387,63 @@ export const readiumAnnotationSetSchema = { }, "required": ["@context", "id", "created", "type", "target"], }, - "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": { + "Selector": { "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"], + // }, }, }; From fff4963f2b17006accd043945143990810145512 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Fri, 13 Dec 2024 11:50:47 +0100 Subject: [PATCH 04/29] apache-annotator --- src/third_party/apache-annotator/dom/css.ts | 123 ++++++ .../apache-annotator/dom/highlight-text.ts | 164 +++++++ src/third_party/apache-annotator/dom/index.ts | 28 ++ .../apache-annotator/dom/normalize-range.ts | 168 +++++++ .../apache-annotator/dom/owner-document.ts | 37 ++ .../apache-annotator/dom/range/cartesian.ts | 91 ++++ .../apache-annotator/dom/range/index.ts | 24 + .../apache-annotator/dom/range/match.ts | 128 ++++++ .../apache-annotator/dom/text-node-chunker.ts | 173 ++++++++ .../dom/text-position/describe.ts | 75 ++++ .../dom/text-position/index.ts | 25 ++ .../dom/text-position/match.ts | 71 +++ .../dom/text-quote/describe.ts | 80 ++++ .../apache-annotator/dom/text-quote/index.ts | 25 ++ .../apache-annotator/dom/text-quote/match.ts | 90 ++++ .../apache-annotator/dom/to-range.ts | 48 ++ .../apache-annotator/selector/index.ts | 33 ++ .../apache-annotator/selector/refinable.ts | 88 ++++ .../apache-annotator/selector/text/chunker.ts | 160 +++++++ .../selector/text/code-point-seeker.ts | 199 +++++++++ .../selector/text/describe-text-position.ts | 64 +++ .../selector/text/describe-text-quote.ts | 301 +++++++++++++ .../apache-annotator/selector/text/index.ts | 28 ++ .../selector/text/match-text-position.ts | 79 ++++ .../selector/text/match-text-quote.ts | 214 +++++++++ .../apache-annotator/selector/text/seeker.ts | 418 ++++++++++++++++++ .../apache-annotator/selector/types.ts | 108 +++++ 27 files changed, 3042 insertions(+) create mode 100644 src/third_party/apache-annotator/dom/css.ts create mode 100644 src/third_party/apache-annotator/dom/highlight-text.ts create mode 100644 src/third_party/apache-annotator/dom/index.ts create mode 100644 src/third_party/apache-annotator/dom/normalize-range.ts create mode 100644 src/third_party/apache-annotator/dom/owner-document.ts create mode 100644 src/third_party/apache-annotator/dom/range/cartesian.ts create mode 100644 src/third_party/apache-annotator/dom/range/index.ts create mode 100644 src/third_party/apache-annotator/dom/range/match.ts create mode 100644 src/third_party/apache-annotator/dom/text-node-chunker.ts create mode 100644 src/third_party/apache-annotator/dom/text-position/describe.ts create mode 100644 src/third_party/apache-annotator/dom/text-position/index.ts create mode 100644 src/third_party/apache-annotator/dom/text-position/match.ts create mode 100644 src/third_party/apache-annotator/dom/text-quote/describe.ts create mode 100644 src/third_party/apache-annotator/dom/text-quote/index.ts create mode 100644 src/third_party/apache-annotator/dom/text-quote/match.ts create mode 100644 src/third_party/apache-annotator/dom/to-range.ts create mode 100644 src/third_party/apache-annotator/selector/index.ts create mode 100644 src/third_party/apache-annotator/selector/refinable.ts create mode 100644 src/third_party/apache-annotator/selector/text/chunker.ts create mode 100644 src/third_party/apache-annotator/selector/text/code-point-seeker.ts create mode 100644 src/third_party/apache-annotator/selector/text/describe-text-position.ts create mode 100644 src/third_party/apache-annotator/selector/text/describe-text-quote.ts create mode 100644 src/third_party/apache-annotator/selector/text/index.ts create mode 100644 src/third_party/apache-annotator/selector/text/match-text-position.ts create mode 100644 src/third_party/apache-annotator/selector/text/match-text-quote.ts create mode 100644 src/third_party/apache-annotator/selector/text/seeker.ts create mode 100644 src/third_party/apache-annotator/selector/types.ts 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..ed1a09bd0 --- /dev/null +++ b/src/third_party/apache-annotator/dom/css.ts @@ -0,0 +1,123 @@ +/** + * @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 { finder } from '@medv/finder'; +import type { CssSelector, Matcher } from '../selector/types.js'; +import { ownerDocument } from './owner-document.js'; +import { toRange } from './to-range.js'; + +/** + * 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: CssSelector, +): 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, { 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..3a2448dc8 --- /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.js'; +import { toRange } from './to-range.js'; + +/** + * 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..6969ea9e1 --- /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.js'; +export * from './range/index.js'; +export * from './text-quote/index.js'; +export * from './text-position/index.js'; +export * from './highlight-text.js'; 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..562f3ac04 --- /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.js'; + +/** + * 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..b9f09433f --- /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..8ee63d405 --- /dev/null +++ b/src/third_party/apache-annotator/dom/range/cartesian.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 + */ + +/** + * 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 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. + 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..5640261c7 --- /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.js'; 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..7525eb0e9 --- /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.js'; + +import { ownerDocument } from '../owner-document.js'; +import { toRange } from '../to-range.js'; +import { cartesian } from './cartesian.js'; + +/** + * 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..dea72c7ad --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-node-chunker.ts @@ -0,0 +1,173 @@ +/** + * @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.js'; +import { normalizeRange } from './normalize-range.js'; +import { ownerDocument } from './owner-document.js'; +import { toRange } from './to-range.js'; + +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.', + ); + } +} + +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 + ); + }, + }; + } + + 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 }; + } + + 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..d52167190 --- /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 type { TextPositionSelector } from "../../selector/types.js"; +import { describeTextPosition as abstractDescribeTextPosition } from '../../selector/text/describe-text-position.js'; +import { ownerDocument } from '../owner-document.js'; +import { TextNodeChunker } from '../text-node-chunker.js'; +import { toRange } from '../to-range.js'; + +/** + * 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..2bb2adcb2 --- /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.js'; +export * from './match.js'; 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..567236903 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-position/match.ts @@ -0,0 +1,71 @@ +/** + * @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, TextPositionSelector } from '../../selector/types.js'; +import { textPositionSelectorMatcher as abstractTextPositionSelectorMatcher } from '../../selector/text/match-text-position.js'; +import { TextNodeChunker } from '../text-node-chunker.js'; + +/** + * 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: TextPositionSelector, +): 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..020cb7ba1 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-quote/describe.ts @@ -0,0 +1,80 @@ +/** + * @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 { + TextQuoteSelector, +} from '../../selector/types.js'; +import { describeTextQuote as abstractDescribeTextQuote, type DescribeTextQuoteOptions } from '../../selector/text/describe-text-quote.js'; +import { ownerDocument } from '../owner-document.js'; +import { TextNodeChunker } from '../text-node-chunker.js'; +import { toRange } from '../to-range.js'; + +/** + * 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..2bb2adcb2 --- /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.js'; +export * from './match.js'; 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..4c1c76eea --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-quote/match.ts @@ -0,0 +1,90 @@ +/** + * @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, TextQuoteSelector } from '../../selector/types.js'; +import { textQuoteSelectorMatcher as abstractTextQuoteSelectorMatcher } from '../../selector/text/match-text-quote.js'; +import { TextNodeChunker, EmptyScopeError } from '../text-node-chunker.js'; + +/** + * 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: TextQuoteSelector, +): 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..d65170aa2 --- /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.js'; + +/** + * 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..2c15f8f8f --- /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.js'; +export * from './text/index.js'; +export * from './refinable.js'; 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..9bb1c1ad1 --- /dev/null +++ b/src/third_party/apache-annotator/selector/refinable.ts @@ -0,0 +1,88 @@ +/** + * @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, Selector } from './types.js'; + +/** + * 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 Selector, + TScope, + // To enable refinement, the implementation’s Match object must be usable as a + // Scope object itself. + TMatch extends TScope +>( + matcherCreator: (selector: Refinable) => Matcher, +): (selector: Refinable) => Matcher { + return function createMatcherWithRefinement( + sourceSelector: Refinable, + ): 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..0824c6e82 --- /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..1d20c59f7 --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/code-point-seeker.ts @@ -0,0 +1,199 @@ +/** + * @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.js'; +import type { Seeker } from './seeker.js'; + +/** + * 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; + } + + 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..bbe035af7 --- /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 type { TextPositionSelector } from '../types.js'; +import type { Chunk, Chunker, ChunkRange } from './chunker.js'; +import { CodePointSeeker } from './code-point-seeker.js'; +import { TextSeeker } from './seeker.js'; + +/** + * 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..737589c3e --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/describe-text-quote.ts @@ -0,0 +1,301 @@ +/** + * @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 { TextQuoteSelector } from '../types.js'; +import type { Chunk, Chunker, ChunkRange } from './chunker.js'; +import { chunkRangeEquals } from './chunker.js'; +import { textQuoteSelectorMatcher } from './match-text-quote.js'; +import type { RelativeSeeker } from './seeker.js'; +import { TextSeeker } from './seeker.js'; + +/** + * @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: TextQuoteSelector = { + type: 'TextQuoteSelector', + exact, + prefix, + suffix, + }; + + const matches = textQuoteSelectorMatcher(tentativeSelector)(scope()); + let nextMatch = await matches.next(); + + // 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 && chunkRangeEquals(nextMatch.value, 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; + + // 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 (err) { + 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..062af902c --- /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.js'; +export * from './match-text-quote.js'; +export * from './describe-text-position.js'; +export * from './match-text-position.js'; +export * from './chunker.js'; 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..7c6e5379b --- /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.js'; +import type { Chunk, ChunkRange, Chunker } from './chunker.js'; +import { CodePointSeeker } from './code-point-seeker.js'; +import { TextSeeker } from './seeker.js'; + +/** + * 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..2f6fe5e6c --- /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 type { TextQuoteSelector } from '../types.js'; +import type { Chunk, Chunker, ChunkRange } from './chunker.js'; + +/** + * 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: TextQuoteSelector, +): >( + 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..b5b76da5e --- /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.js'; +import { chunkEquals } from './chunker.js'; + +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..e6e583865 --- /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; +} From 6fed1983c831e77da78667127c27129448a4084b Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Fri, 13 Dec 2024 18:18:37 +0100 Subject: [PATCH 05/29] lint and revert unused addAnnotationToReaderPublicatrion action previously used to import from main process without virtualDom --- .../annotation/annotationModel.type.ts | 2 +- src/common/readium/annotation/converter.ts | 25 +++++--- .../addAnnotationToReaderPublication.ts | 50 +++++++-------- src/main/redux/actions/win/registry/index.ts | 4 +- .../redux/reducers/win/registry/reader.ts | 62 +++++++++---------- 5 files changed, 74 insertions(+), 69 deletions(-) diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts index 5b42318aa..d0589a4f1 100644 --- a/src/common/readium/annotation/annotationModel.type.ts +++ b/src/common/readium/annotation/annotationModel.type.ts @@ -392,7 +392,7 @@ export const readiumAnnotationSetSchema = { "properties": { "type": { "type": "string", - } + }, }, "required": ["type"], }, diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index 6cdfa0b0e..a6e9d0978 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -12,12 +12,14 @@ 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 { describeTextPosition } from "readium-desktop/third_party/apache-annotator/dom/text-position"; + export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotationState): IReadiumAnnotation { 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 { href /*text, locations*/ } = locator; + // const { afterRaw, beforeRaw, highlightRaw } = text || {}; // const { rangeInfo: rangeInfoSelection } = selectionInfo || {}; // const { progression } = locations; @@ -25,14 +27,17 @@ export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotatio const selector: IReadiumAnnotation["target"]["selector"] = []; - if (highlightRaw && afterRaw && beforeRaw) { - selector.push({ - type: "TextQuoteSelector", - exact: highlightRaw, - prefix: beforeRaw, - suffix: afterRaw, - }); - } + // if (highlightRaw && afterRaw && beforeRaw) { + // selector.push({ + // type: "TextQuoteSelector", + // exact: highlightRaw, + // prefix: beforeRaw, + // suffix: afterRaw, + // }); + // } + + + // need to convert locator to Range and convert it with apache annotator to TextQuote and TextPosition, and in a second time : CssSelectorWithTextPositionSelector ! return { "@context": "http://www.w3.org/ns/anno.jsonld", diff --git a/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts b/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts index d641f85c7..3e43437a8 100644 --- a/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts +++ b/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts @@ -1,30 +1,30 @@ -// ==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== +// // ==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 } from "readium-desktop/common/models/redux"; -import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +// import { Action } from "readium-desktop/common/models/redux"; +// import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; -export const ID = "WIN_REGISTRY_REGISTER_ADD_ANNOTATION"; +// export const ID = "WIN_REGISTRY_REGISTER_ADD_ANNOTATION"; -export interface Payload { - publicationIdentifier: string; - annotations: IAnnotationState[]; -} +// export interface Payload { +// publicationIdentifier: string; +// annotations: IAnnotationState[]; +// } -export function build(publicationIdentifier: string, annotations: IAnnotationState[]): - Action { +// export function build(publicationIdentifier: string, annotations: IAnnotationState[]): +// Action { - return { - type: ID, - payload: { - publicationIdentifier, - annotations, - }, - }; -} -build.toString = () => ID; // Redux StringableActionCreator -export type TAction = ReturnType; +// return { +// type: ID, +// payload: { +// publicationIdentifier, +// annotations, +// }, +// }; +// } +// build.toString = () => ID; // Redux StringableActionCreator +// export type TAction = ReturnType; diff --git a/src/main/redux/actions/win/registry/index.ts b/src/main/redux/actions/win/registry/index.ts index 186befb75..19a859fc4 100644 --- a/src/main/redux/actions/win/registry/index.ts +++ b/src/main/redux/actions/win/registry/index.ts @@ -7,10 +7,10 @@ import * as registerReaderPublication from "./registerReaderPublication"; import * as unregisterReaderPublication from "./unregisterReaderPublication"; -import * as addAnnotationToReaderPublication from "./addAnnotationToReaderPublication"; +// import * as addAnnotationToReaderPublication from "./addAnnotationToReaderPublication"; export { registerReaderPublication, unregisterReaderPublication, - addAnnotationToReaderPublication, + // addAnnotationToReaderPublication, }; diff --git a/src/main/redux/reducers/win/registry/reader.ts b/src/main/redux/reducers/win/registry/reader.ts index eef39f51c..0a240ab15 100644 --- a/src/main/redux/reducers/win/registry/reader.ts +++ b/src/main/redux/reducers/win/registry/reader.ts @@ -9,15 +9,15 @@ 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, + // | winActions.registry.addAnnotationToReaderPublication.TAction, ): IDictWinRegistryReaderState { switch (action.type) { @@ -50,38 +50,38 @@ function winRegistryReaderReducer_( return state; } - case winActions.registry.addAnnotationToReaderPublication.ID: { + // case winActions.registry.addAnnotationToReaderPublication.ID: { - const { publicationIdentifier: id, annotations } = action.payload; + // const { publicationIdentifier: id, annotations } = action.payload; - if (annotations.length && Array.isArray(state[id]?.reduxState?.annotation)) { + // 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, - ], - }, - }, - }, - }, - }, - }; + // 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; - } + // } + // return state; + // } default: return state; From effd92ecbacca0c00588326160cb24447b59bb2d Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Fri, 13 Dec 2024 18:22:13 +0100 Subject: [PATCH 06/29] fix seachCachePublicationResources from search to ReaderRootState at the state root level. Now not specific to the search, search rely on this resourceCache just like import/export annotations routine --- .../redux/states/renderer/readerRootState.ts | 2 ++ .../redux/states/renderer/resourceCache.ts} | 19 +----------- src/common/redux/states/renderer/search.ts | 5 +-- .../reader/components/ReaderMenuSearch.tsx | 2 +- src/renderer/reader/redux/actions/index.ts | 2 ++ .../{search/cache.ts => resourceCache.ts} | 13 ++++---- .../reader/redux/actions/search/index.ts | 2 -- src/renderer/reader/redux/reducers/index.ts | 2 ++ .../reader/redux/reducers/resourceCache.ts | 31 +++++++++++++++++++ src/renderer/reader/redux/reducers/search.ts | 13 +------- src/renderer/reader/redux/sagas/index.ts | 14 +++++++++ src/renderer/reader/redux/sagas/search.ts | 12 +++---- src/utils/search/search.ts | 21 +++++++++++-- src/utils/search/searchWithDomSeek.ts | 2 +- 14 files changed, 87 insertions(+), 53 deletions(-) rename src/{utils/search/search.interface.ts => common/redux/states/renderer/resourceCache.ts} (55%) rename src/renderer/reader/redux/actions/{search/cache.ts => resourceCache.ts} (66%) create mode 100644 src/renderer/reader/redux/reducers/resourceCache.ts diff --git a/src/common/redux/states/renderer/readerRootState.ts b/src/common/redux/states/renderer/readerRootState.ts index 7b3d95685..31e287a3a 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/renderer/reader/components/ReaderMenuSearch.tsx b/src/renderer/reader/components/ReaderMenuSearch.tsx index ba6192df4..af149e98a 100644 --- a/src/renderer/reader/components/ReaderMenuSearch.tsx +++ b/src/renderer/reader/components/ReaderMenuSearch.tsx @@ -22,11 +22,11 @@ import { } from "readium-desktop/renderer/common/components/hoc/translator"; import SVG from "readium-desktop/renderer/common/components/SVG"; import { TDispatch } from "readium-desktop/typings/redux"; -import { ISearchResult } from "readium-desktop/utils/search/search.interface"; import { Link } from "@r2-shared-js/models/publication-link"; import { readerLocalActionSearch } from "../redux/actions"; +import { ISearchResult } from "readium-desktop/utils/search/search"; // eslint-disable-next-line @typescript-eslint/no-empty-interface interface IBaseProps { diff --git a/src/renderer/reader/redux/actions/index.ts b/src/renderer/reader/redux/actions/index.ts index 24e7f6501..4575cb2f5 100644 --- a/src/renderer/reader/redux/actions/index.ts +++ b/src/renderer/reader/redux/actions/index.ts @@ -15,8 +15,10 @@ 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"; export { + 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/reducers/index.ts b/src/renderer/reader/redux/reducers/index.ts index f3b313577..dfd45e08d 100644 --- a/src/renderer/reader/redux/reducers/index.ts +++ b/src/renderer/reader/redux/reducers/index.ts @@ -47,6 +47,7 @@ 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"; export const rootReducer = () => { @@ -182,6 +183,7 @@ export const rootReducer = () => { tts: readerTTSReducer, }), search: searchReducer, + resourceCache: readerResourceCacheReducer, annotation: annotationModeEnableReducer, annotationTagsIndex: annotationTagsIndexReducer, picker: pickerReducer, 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..ad6108f7d 100644 --- a/src/renderer/reader/redux/sagas/index.ts +++ b/src/renderer/reader/redux/sagas/index.ts @@ -78,6 +78,20 @@ export function* rootSaga() { yield call(winInit.render); + + + // if annotationImportQueue not empty then + // start the import process + + // when push action dispatched start the import process + // with cache push action trigger the import process but not in // in sequence + // new annotations in queue => + // shift it => + // process it => + // wait 100ms => + // look if new data in the queue => + // if no new note available, wait new push action + yield all([ i18n.saga(), ipc.saga(), diff --git a/src/renderer/reader/redux/sagas/search.ts b/src/renderer/reader/redux/sagas/search.ts index 295486b7c..c2ca3fbfb 100644 --- a/src/renderer/reader/redux/sagas/search.ts +++ b/src/renderer/reader/redux/sagas/search.ts @@ -11,8 +11,7 @@ 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 { @@ -26,10 +25,11 @@ 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 { readerLocalActionHighlights, readerLocalActionSearch, readerLocalActionSetResourceToCache } from "../actions"; import { IHighlightHandlerState } from "readium-desktop/common/redux/states/renderer/highlight"; import debounce from "debounce"; +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; const handleLinkLocatorDebounced = debounce(handleLinkLocator, 200); @@ -68,7 +68,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) => @@ -208,7 +208,7 @@ function* requestPublicationData() { (state: IReaderRootState) => state.reader.info.manifestUrlR2Protocol, ); const request = r2Manifest.Spine.map((ln) => call(async () => { - const ret: ISearchDocument = { + const ret: ICacheDocument = { xml: "", // initialized in code below href: ln.Href, contentType: ln.TypeLink ? ln.TypeLink : ContentType.Xhtml, @@ -240,7 +240,7 @@ function* requestPublicationData() { })); const result = yield* allTyped(request); - yield put(readerLocalActionSearch.setCache.build(...result)); + yield put(readerLocalActionSetResourceToCache.build(result)); } function* searchEnable(_action: readerLocalActionSearch.enable.TAction) { diff --git a/src/utils/search/search.ts b/src/utils/search/search.ts index 5ab53f038..a6c78f37d 100644 --- a/src/utils/search/search.ts +++ b/src/utils/search/search.ts @@ -11,10 +11,27 @@ import { removeUTF8BOM } from "readium-desktop/common/utils/bom"; import { ContentType } from "../contentType"; -import { ISearchDocument, ISearchResult } from "./search.interface"; import { searchDocDomSeek } from "./searchWithDomSeek"; -export async function search(searchInput: string, data: ISearchDocument): Promise { +import { IRangeInfo } from "@r2-navigator-js/electron/common/selection"; +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; + +export interface ISearchResult { + rangeInfo: IRangeInfo; + + cleanBefore: string; + cleanText: string; + cleanAfter: string; + + // rawBefore: string; + // rawText: string; + // rawAfter: string; + + href: string; + uuid: string; +} + +export async function search(searchInput: string, data: ICacheDocument): Promise { if (!data.xml) { return []; diff --git a/src/utils/search/searchWithDomSeek.ts b/src/utils/search/searchWithDomSeek.ts index b991e6d41..435f93afd 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 From c06ae34e11e0941104390bffd9c008731ae8a773 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Fri, 13 Dec 2024 18:22:41 +0100 Subject: [PATCH 07/29] lint and type fixes on the third-party apache-annotator lib --- src/third_party/apache-annotator/dom/css.ts | 13 ++++---- .../apache-annotator/dom/highlight-text.ts | 6 ++-- src/third_party/apache-annotator/dom/index.ts | 10 +++--- .../apache-annotator/dom/normalize-range.ts | 6 ++-- .../apache-annotator/dom/owner-document.ts | 2 +- .../apache-annotator/dom/range/cartesian.ts | 3 +- .../apache-annotator/dom/range/index.ts | 2 +- .../apache-annotator/dom/range/match.ts | 8 ++--- .../apache-annotator/dom/text-node-chunker.ts | 3 ++ .../dom/text-position/describe.ts | 10 +++--- .../dom/text-position/index.ts | 4 +-- .../dom/text-position/match.ts | 6 ++-- .../dom/text-quote/describe.ts | 10 +++--- .../apache-annotator/dom/text-quote/index.ts | 4 +-- .../apache-annotator/dom/text-quote/match.ts | 6 ++-- .../apache-annotator/dom/to-range.ts | 4 +-- .../apache-annotator/selector/index.ts | 6 ++-- .../apache-annotator/selector/refinable.ts | 2 +- .../apache-annotator/selector/text/chunker.ts | 2 +- .../selector/text/code-point-seeker.ts | 17 +++++----- .../selector/text/describe-text-position.ts | 10 +++--- .../selector/text/describe-text-quote.ts | 32 +++++++++++-------- .../apache-annotator/selector/text/index.ts | 10 +++--- .../selector/text/match-text-position.ts | 8 ++--- .../selector/text/match-text-quote.ts | 8 ++--- .../apache-annotator/selector/text/seeker.ts | 10 +++--- .../apache-annotator/selector/types.ts | 8 ++--- 27 files changed, 110 insertions(+), 100 deletions(-) diff --git a/src/third_party/apache-annotator/dom/css.ts b/src/third_party/apache-annotator/dom/css.ts index ed1a09bd0..2ed1d1946 100644 --- a/src/third_party/apache-annotator/dom/css.ts +++ b/src/third_party/apache-annotator/dom/css.ts @@ -21,10 +21,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { finder } from '@medv/finder'; -import type { CssSelector, Matcher } from '../selector/types.js'; -import { ownerDocument } from './owner-document.js'; -import { toRange } from './to-range.js'; +import { uniqueCssSelector as finder } from "@r2-navigator-js/electron/renderer/common/cssselector2-3"; + +import type { CssSelector, Matcher } from "../selector/types.js"; +import { ownerDocument } from "./owner-document.js"; +import { toRange } from "./to-range.js"; /** * Find the elements corresponding to the given {@link @@ -115,9 +116,9 @@ export async function describeCss( element: HTMLElement, scope: Element = element.ownerDocument.documentElement, ): Promise { - const selector = finder(element, { root: scope }); + const selector = finder(element, element.ownerDocument, { root: scope }); return { - type: 'CssSelector', + 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 index 3a2448dc8..b2ca976eb 100644 --- a/src/third_party/apache-annotator/dom/highlight-text.ts +++ b/src/third_party/apache-annotator/dom/highlight-text.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ownerDocument } from './owner-document.js'; -import { toRange } from './to-range.js'; +import { ownerDocument } from "./owner-document.js"; +import { toRange } from "./to-range.js"; /** * Wrap each text node in a given Node or Range with a `` or other @@ -48,7 +48,7 @@ import { toRange } from './to-range.js'; */ export function highlightText( target: Node | Range, - tagName = 'mark', + tagName = "mark", attributes: Record = {}, ): () => void { // First put all nodes in an array (splits start and end nodes if needed) diff --git a/src/third_party/apache-annotator/dom/index.ts b/src/third_party/apache-annotator/dom/index.ts index 6969ea9e1..94bb009a4 100644 --- a/src/third_party/apache-annotator/dom/index.ts +++ b/src/third_party/apache-annotator/dom/index.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './css.js'; -export * from './range/index.js'; -export * from './text-quote/index.js'; -export * from './text-position/index.js'; -export * from './highlight-text.js'; +export * from "./css.js"; +export * from "./range/index.js"; +export * from "./text-quote/index.js"; +export * from "./text-position/index.js"; +export * from "./highlight-text.js"; diff --git a/src/third_party/apache-annotator/dom/normalize-range.ts b/src/third_party/apache-annotator/dom/normalize-range.ts index 562f3ac04..9199917a3 100644 --- a/src/third_party/apache-annotator/dom/normalize-range.ts +++ b/src/third_party/apache-annotator/dom/normalize-range.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ownerDocument } from './owner-document.js'; +import { ownerDocument } from "./owner-document.js"; /** * TextRange is a Range that guarantees to always have Text nodes as its start @@ -134,7 +134,7 @@ function snapBoundaryPointToTextNode( while (curNode.nextSibling === null) { if (curNode.parentNode === null) // Boundary point is at end of document - throw new Error('not implemented'); // TODO + throw new Error("not implemented"); // TODO curNode = curNode.parentNode; } curNode = curNode.nextSibling; @@ -151,7 +151,7 @@ function snapBoundaryPointToTextNode( } else if (walker.previousNode() !== null) { return [walker.currentNode as Text, (walker.currentNode as Text).length]; } else { - throw new Error('Document contains no text nodes.'); + throw new Error("Document contains no text nodes."); } } diff --git a/src/third_party/apache-annotator/dom/owner-document.ts b/src/third_party/apache-annotator/dom/owner-document.ts index b9f09433f..9a2d8c698 100644 --- a/src/third_party/apache-annotator/dom/owner-document.ts +++ b/src/third_party/apache-annotator/dom/owner-document.ts @@ -33,5 +33,5 @@ export function ownerDocument(nodeOrRange: Node | Range): Document { } function isRange(nodeOrRange: Node | Range): nodeOrRange is Range { - return 'startContainer' in nodeOrRange; + 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 index 8ee63d405..b2fee07cb 100644 --- a/src/third_party/apache-annotator/dom/range/cartesian.ts +++ b/src/third_party/apache-annotator/dom/range/cartesian.ts @@ -46,7 +46,7 @@ export async function* cartesian( let active = iterators.length; // Track all the values of each iterator in a log. - const logs = iterators.map(() => []) as T[][]; + 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()); @@ -68,6 +68,7 @@ export async function* cartesian( } // Append the new value to the log. + // @ts-expect-error const { value } = result.value; logs[index].push(value); diff --git a/src/third_party/apache-annotator/dom/range/index.ts b/src/third_party/apache-annotator/dom/range/index.ts index 5640261c7..66ca90af8 100644 --- a/src/third_party/apache-annotator/dom/range/index.ts +++ b/src/third_party/apache-annotator/dom/range/index.ts @@ -21,4 +21,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './match.js'; +export * from "./match.js"; diff --git a/src/third_party/apache-annotator/dom/range/match.ts b/src/third_party/apache-annotator/dom/range/match.ts index 7525eb0e9..dc72587e0 100644 --- a/src/third_party/apache-annotator/dom/range/match.ts +++ b/src/third_party/apache-annotator/dom/range/match.ts @@ -25,11 +25,11 @@ import type { Matcher, RangeSelector, Selector, -} from '../../selector/types.js'; +} from "../../selector/types.js"; -import { ownerDocument } from '../owner-document.js'; -import { toRange } from '../to-range.js'; -import { cartesian } from './cartesian.js'; +import { ownerDocument } from "../owner-document.js"; +import { toRange } from "../to-range.js"; +import { cartesian } from "./cartesian.js"; /** * Find the range(s) corresponding to the given {@link RangeSelector}. diff --git a/src/third_party/apache-annotator/dom/text-node-chunker.ts b/src/third_party/apache-annotator/dom/text-node-chunker.ts index dea72c7ad..8e30a92bc 100644 --- a/src/third_party/apache-annotator/dom/text-node-chunker.ts +++ b/src/third_party/apache-annotator/dom/text-node-chunker.ts @@ -47,6 +47,7 @@ export class OutOfScopeError extends TypeError { } } +//@ts-expect-error export class TextNodeChunker implements Chunker { private scope: Range; private iter: NodeIterator; @@ -83,6 +84,7 @@ export class TextNodeChunker implements Chunker { }; } +//@ts-expect-error rangeToChunkRange(range: Range): ChunkRange { range = range.cloneRange(); @@ -103,6 +105,7 @@ export class TextNodeChunker implements Chunker { return { startChunk, startIndex, endChunk, endIndex }; } +//@ts-expect-error chunkRangeToRange(chunkRange: ChunkRange): Range { const range = ownerDocument(this.scope).createRange(); // The `+…startOffset` parts are only relevant for the first chunk, as it diff --git a/src/third_party/apache-annotator/dom/text-position/describe.ts b/src/third_party/apache-annotator/dom/text-position/describe.ts index d52167190..f320a1185 100644 --- a/src/third_party/apache-annotator/dom/text-position/describe.ts +++ b/src/third_party/apache-annotator/dom/text-position/describe.ts @@ -22,10 +22,10 @@ */ import type { TextPositionSelector } from "../../selector/types.js"; -import { describeTextPosition as abstractDescribeTextPosition } from '../../selector/text/describe-text-position.js'; -import { ownerDocument } from '../owner-document.js'; -import { TextNodeChunker } from '../text-node-chunker.js'; -import { toRange } from '../to-range.js'; +import { describeTextPosition as abstractDescribeTextPosition } from "../../selector/text/describe-text-position.js"; +import { ownerDocument } from "../owner-document.js"; +import { TextNodeChunker } from "../text-node-chunker.js"; +import { toRange } from "../to-range.js"; /** * Returns a {@link TextPositionSelector} that points at the target text within @@ -66,7 +66,7 @@ export async function describeTextPosition( const textChunks = new TextNodeChunker(scope); if (textChunks.currentChunk === null) - throw new RangeError('Scope does not contain any Text nodes.'); + throw new RangeError("Scope does not contain any Text nodes."); return await abstractDescribeTextPosition( textChunks.rangeToChunkRange(range), diff --git a/src/third_party/apache-annotator/dom/text-position/index.ts b/src/third_party/apache-annotator/dom/text-position/index.ts index 2bb2adcb2..fb2c93d59 100644 --- a/src/third_party/apache-annotator/dom/text-position/index.ts +++ b/src/third_party/apache-annotator/dom/text-position/index.ts @@ -21,5 +21,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './describe.js'; -export * from './match.js'; +export * from "./describe.js"; +export * from "./match.js"; diff --git a/src/third_party/apache-annotator/dom/text-position/match.ts b/src/third_party/apache-annotator/dom/text-position/match.ts index 567236903..f6f059492 100644 --- a/src/third_party/apache-annotator/dom/text-position/match.ts +++ b/src/third_party/apache-annotator/dom/text-position/match.ts @@ -21,9 +21,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, TextPositionSelector } from '../../selector/types.js'; -import { textPositionSelectorMatcher as abstractTextPositionSelectorMatcher } from '../../selector/text/match-text-position.js'; -import { TextNodeChunker } from '../text-node-chunker.js'; +import type { Matcher, TextPositionSelector } from "../../selector/types.js"; +import { textPositionSelectorMatcher as abstractTextPositionSelectorMatcher } from "../../selector/text/match-text-position.js"; +import { TextNodeChunker } from "../text-node-chunker.js"; /** * Find the range of text corresponding to the given {@link diff --git a/src/third_party/apache-annotator/dom/text-quote/describe.ts b/src/third_party/apache-annotator/dom/text-quote/describe.ts index 020cb7ba1..afacff58f 100644 --- a/src/third_party/apache-annotator/dom/text-quote/describe.ts +++ b/src/third_party/apache-annotator/dom/text-quote/describe.ts @@ -23,11 +23,11 @@ import type { TextQuoteSelector, -} from '../../selector/types.js'; -import { describeTextQuote as abstractDescribeTextQuote, type DescribeTextQuoteOptions } from '../../selector/text/describe-text-quote.js'; -import { ownerDocument } from '../owner-document.js'; -import { TextNodeChunker } from '../text-node-chunker.js'; -import { toRange } from '../to-range.js'; +} from "../../selector/types.js"; +import { describeTextQuote as abstractDescribeTextQuote, type DescribeTextQuoteOptions } from "../../selector/text/describe-text-quote.js"; +import { ownerDocument } from "../owner-document.js"; +import { TextNodeChunker } from "../text-node-chunker.js"; +import { toRange } from "../to-range.js"; /** * Returns a {@link TextQuoteSelector} that unambiguously describes the given diff --git a/src/third_party/apache-annotator/dom/text-quote/index.ts b/src/third_party/apache-annotator/dom/text-quote/index.ts index 2bb2adcb2..fb2c93d59 100644 --- a/src/third_party/apache-annotator/dom/text-quote/index.ts +++ b/src/third_party/apache-annotator/dom/text-quote/index.ts @@ -21,5 +21,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './describe.js'; -export * from './match.js'; +export * from "./describe.js"; +export * from "./match.js"; diff --git a/src/third_party/apache-annotator/dom/text-quote/match.ts b/src/third_party/apache-annotator/dom/text-quote/match.ts index 4c1c76eea..bd120ac9b 100644 --- a/src/third_party/apache-annotator/dom/text-quote/match.ts +++ b/src/third_party/apache-annotator/dom/text-quote/match.ts @@ -21,9 +21,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, TextQuoteSelector } from '../../selector/types.js'; -import { textQuoteSelectorMatcher as abstractTextQuoteSelectorMatcher } from '../../selector/text/match-text-quote.js'; -import { TextNodeChunker, EmptyScopeError } from '../text-node-chunker.js'; +import type { Matcher, TextQuoteSelector } from "../../selector/types.js"; +import { textQuoteSelectorMatcher as abstractTextQuoteSelectorMatcher } from "../../selector/text/match-text-quote.js"; +import { TextNodeChunker, EmptyScopeError } from "../text-node-chunker.js"; /** * Find occurrences in a text matching the given {@link diff --git a/src/third_party/apache-annotator/dom/to-range.ts b/src/third_party/apache-annotator/dom/to-range.ts index d65170aa2..7d9aa2005 100644 --- a/src/third_party/apache-annotator/dom/to-range.ts +++ b/src/third_party/apache-annotator/dom/to-range.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ownerDocument } from './owner-document.js'; +import { ownerDocument } from "./owner-document.js"; /** * Returns a range that exactly selects the contents of the given node. @@ -44,5 +44,5 @@ export function toRange(nodeOrRange: Node | Range): Range { } function isRange(nodeOrRange: Node | Range): nodeOrRange is Range { - return 'startContainer' in nodeOrRange; + return "startContainer" in nodeOrRange; } diff --git a/src/third_party/apache-annotator/selector/index.ts b/src/third_party/apache-annotator/selector/index.ts index 2c15f8f8f..31f8dae62 100644 --- a/src/third_party/apache-annotator/selector/index.ts +++ b/src/third_party/apache-annotator/selector/index.ts @@ -28,6 +28,6 @@ export type { RangeSelector, TextPositionSelector, TextQuoteSelector, -} from './types.js'; -export * from './text/index.js'; -export * from './refinable.js'; +} from "./types.js"; +export * from "./text/index.js"; +export * from "./refinable.js"; diff --git a/src/third_party/apache-annotator/selector/refinable.ts b/src/third_party/apache-annotator/selector/refinable.ts index 9bb1c1ad1..1b96b6408 100644 --- a/src/third_party/apache-annotator/selector/refinable.ts +++ b/src/third_party/apache-annotator/selector/refinable.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, Selector } from './types.js'; +import type { Matcher, Selector } from "./types.js"; /** * A Refinable selector can have the `refinedBy` attribute, whose value must be diff --git a/src/third_party/apache-annotator/selector/text/chunker.ts b/src/third_party/apache-annotator/selector/text/chunker.ts index 0824c6e82..cb1147d15 100644 --- a/src/third_party/apache-annotator/selector/text/chunker.ts +++ b/src/third_party/apache-annotator/selector/text/chunker.ts @@ -39,7 +39,7 @@ export interface Chunk { * The piece of text this chunk represents. */ readonly data: TData; - equals?(otherChunk: this): boolean; + equals?: (otherChunk: this) => 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 index 1d20c59f7..a4f9565d4 100644 --- a/src/third_party/apache-annotator/selector/text/code-point-seeker.ts +++ b/src/third_party/apache-annotator/selector/text/code-point-seeker.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Chunk } from './chunker.js'; -import type { Seeker } from './seeker.js'; +import type { Chunk } from "./chunker.js"; +import type { Seeker } from "./seeker.js"; /** * Seeks through text counting Unicode *code points* instead of *code units*. @@ -118,6 +118,7 @@ export class CodePointSeeker> : this.position - result.length; if (read) return result; + return undefined; } private _readOrSeekTo( @@ -134,7 +135,7 @@ export class CodePointSeeker> let result: string[] = []; if (this.position < target) { - let unpairedSurrogate = ''; + let unpairedSurrogate = ""; let characters: string[] = []; while (this.position < target) { let s = unpairedSurrogate + this.raw.read(1, true); @@ -142,7 +143,7 @@ export class CodePointSeeker> unpairedSurrogate = s.slice(-1); // consider this half-character part of the next string. s = s.slice(0, -1); } else { - unpairedSurrogate = ''; + unpairedSurrogate = ""; } characters = [...s]; this.position += characters.length; @@ -153,13 +154,13 @@ export class CodePointSeeker> const overshootInCodePoints = this.position - target; const overshootInCodeUnits = characters .slice(-overshootInCodePoints) - .join('').length; + .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 unpairedSurrogate = ""; let characters: string[] = []; while (this.position > target) { let s = this.raw.read(-1, true) + unpairedSurrogate; @@ -167,7 +168,7 @@ export class CodePointSeeker> unpairedSurrogate = s[0]; s = s.slice(1); } else { - unpairedSurrogate = ''; + unpairedSurrogate = ""; } characters = [...s]; this.position -= characters.length; @@ -178,7 +179,7 @@ export class CodePointSeeker> const overshootInCodePoints = target - this.position; const overshootInCodeUnits = characters .slice(0, overshootInCodePoints) - .join('').length; + .join("").length; this.position += overshootInCodePoints; this.raw.seekBy(overshootInCodeUnits); } 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 index bbe035af7..9d8b43721 100644 --- a/src/third_party/apache-annotator/selector/text/describe-text-position.ts +++ b/src/third_party/apache-annotator/selector/text/describe-text-position.ts @@ -21,10 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextPositionSelector } from '../types.js'; -import type { Chunk, Chunker, ChunkRange } from './chunker.js'; -import { CodePointSeeker } from './code-point-seeker.js'; -import { TextSeeker } from './seeker.js'; +import type { TextPositionSelector } from "../types.js"; +import type { Chunk, Chunker, ChunkRange } from "./chunker.js"; +import { CodePointSeeker } from "./code-point-seeker.js"; +import { TextSeeker } from "./seeker.js"; /** * Returns a {@link TextPositionSelector} that points at the target text within @@ -57,7 +57,7 @@ export async function describeTextPosition>( codePointSeeker.seekToChunk(target.endChunk, target.endIndex); const end = codePointSeeker.position; return { - type: 'TextPositionSelector', + 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 index 737589c3e..63bedfd5c 100644 --- a/src/third_party/apache-annotator/selector/text/describe-text-quote.ts +++ b/src/third_party/apache-annotator/selector/text/describe-text-quote.ts @@ -21,12 +21,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextQuoteSelector } from '../types.js'; -import type { Chunk, Chunker, ChunkRange } from './chunker.js'; -import { chunkRangeEquals } from './chunker.js'; -import { textQuoteSelectorMatcher } from './match-text-quote.js'; -import type { RelativeSeeker } from './seeker.js'; -import { TextSeeker } from './seeker.js'; +import type { TextQuoteSelector } from "../types.js"; +import type { Chunk, Chunker, ChunkRange } from "./chunker.js"; +import { chunkRangeEquals } from "./chunker.js"; +import { textQuoteSelectorMatcher } from "./match-text-quote.js"; +import type { RelativeSeeker } from "./seeker.js"; +import { TextSeeker } from "./seeker.js"; /** * @public @@ -103,8 +103,8 @@ export async function describeTextQuote>( const exact = seekerAtTarget.readToChunk(target.endChunk, target.endIndex); // Start with an empty prefix and suffix. - let prefix = ''; - let 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; @@ -157,7 +157,7 @@ export async function describeTextQuote>( // ensure it will no longer match. while (true) { const tentativeSelector: TextQuoteSelector = { - type: 'TextQuoteSelector', + type: "TextQuoteSelector", exact, prefix, suffix, @@ -165,10 +165,11 @@ export async function describeTextQuote>( 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 && chunkRangeEquals(nextMatch.value, target)) { + if (!nextMatch.done && nextMatchValue && chunkRangeEquals(nextMatchValue, target)) { nextMatch = await matches.next(); } @@ -184,6 +185,9 @@ export async function describeTextQuote>( // 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( @@ -232,7 +236,7 @@ export async function describeTextQuote>( suffix = suffix + extraSuffix; } else { throw new Error( - 'Target cannot be disambiguated; how could that have happened‽', + "Target cannot be disambiguated; how could that have happened‽", ); } } else { @@ -248,12 +252,12 @@ function readUntilDifferent( seeker2: RelativeSeeker, reverse: boolean, ): string | undefined { - let result = ''; + let result = ""; while (true) { let nextCharacter: string; try { nextCharacter = seeker1.read(reverse ? -1 : 1); - } catch (err) { + } catch { return undefined; // Start/end of text reached: cannot expand result. } result = reverse ? nextCharacter + result : result + nextCharacter; @@ -275,7 +279,7 @@ function readUntilWhitespace( limit = Infinity, reverse = false, ): string { - let result = ''; + let result = ""; while (result.length < limit) { let nextCharacter: string; try { diff --git a/src/third_party/apache-annotator/selector/text/index.ts b/src/third_party/apache-annotator/selector/text/index.ts index 062af902c..6f5d4961b 100644 --- a/src/third_party/apache-annotator/selector/text/index.ts +++ b/src/third_party/apache-annotator/selector/text/index.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './describe-text-quote.js'; -export * from './match-text-quote.js'; -export * from './describe-text-position.js'; -export * from './match-text-position.js'; -export * from './chunker.js'; +export * from "./describe-text-quote.js"; +export * from "./match-text-quote.js"; +export * from "./describe-text-position.js"; +export * from "./match-text-position.js"; +export * from "./chunker.js"; 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 index 7c6e5379b..6891f13d9 100644 --- a/src/third_party/apache-annotator/selector/text/match-text-position.ts +++ b/src/third_party/apache-annotator/selector/text/match-text-position.ts @@ -21,10 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextPositionSelector } from '../types.js'; -import type { Chunk, ChunkRange, Chunker } from './chunker.js'; -import { CodePointSeeker } from './code-point-seeker.js'; -import { TextSeeker } from './seeker.js'; +import type { TextPositionSelector } from "../types.js"; +import type { Chunk, ChunkRange, Chunker } from "./chunker.js"; +import { CodePointSeeker } from "./code-point-seeker.js"; +import { TextSeeker } from "./seeker.js"; /** * Find the range of text corresponding to the given {@link TextPositionSelector}. 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 index 2f6fe5e6c..a2e02cc2c 100644 --- a/src/third_party/apache-annotator/selector/text/match-text-quote.ts +++ b/src/third_party/apache-annotator/selector/text/match-text-quote.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextQuoteSelector } from '../types.js'; -import type { Chunk, Chunker, ChunkRange } from './chunker.js'; +import type { TextQuoteSelector } from "../types.js"; +import type { Chunk, Chunker, ChunkRange } from "./chunker.js"; /** * Find occurrences in a text matching the given {@link TextQuoteSelector}. @@ -74,8 +74,8 @@ export function textQuoteSelectorMatcher( textChunks: Chunker, ) { const exact = selector.exact; - const prefix = selector.prefix || ''; - const suffix = selector.suffix || ''; + const prefix = selector.prefix || ""; + const suffix = selector.suffix || ""; const searchPattern = prefix + exact + suffix; // The code below essentially just performs string.indexOf(searchPattern), diff --git a/src/third_party/apache-annotator/selector/text/seeker.ts b/src/third_party/apache-annotator/selector/text/seeker.ts index b5b76da5e..f7301abd0 100644 --- a/src/third_party/apache-annotator/selector/text/seeker.ts +++ b/src/third_party/apache-annotator/selector/text/seeker.ts @@ -21,10 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Chunk, Chunker } from './chunker.js'; -import { chunkEquals } from './chunker.js'; +import type { Chunk, Chunker } from "./chunker.js"; +import { chunkEquals } from "./chunker.js"; -const E_END = 'Iterator exhausted before seek ended.'; +const E_END = "Iterator exhausted before seek ended."; /** * Abstraction to seek (jump) or read to a position inside a ‘file’ consisting of a @@ -267,7 +267,7 @@ export class TextSeeker> offset = 0, ): string | void { const oldPosition = this.position; - let result = ''; + let result = ""; // Walk to the requested chunk. if (!this.chunker.precedesCurrentChunk(target)) { @@ -327,7 +327,7 @@ export class TextSeeker> roundUp = false, lessIsFine = false, ): string | void { - let result = ''; + let result = ""; if (this.position <= target) { while (true) { diff --git a/src/third_party/apache-annotator/selector/types.ts b/src/third_party/apache-annotator/selector/types.ts index e6e583865..74148260f 100644 --- a/src/third_party/apache-annotator/selector/types.ts +++ b/src/third_party/apache-annotator/selector/types.ts @@ -50,7 +50,7 @@ export interface Selector { * @public */ export interface CssSelector extends Selector { - type: 'CssSelector'; + type: "CssSelector"; value: string; } @@ -63,7 +63,7 @@ export interface CssSelector extends Selector { * @public */ export interface TextQuoteSelector extends Selector { - type: 'TextQuoteSelector'; + type: "TextQuoteSelector"; exact: string; prefix?: string; suffix?: string; @@ -78,7 +78,7 @@ export interface TextQuoteSelector extends Selector { * @public */ export interface TextPositionSelector extends Selector { - type: 'TextPositionSelector'; + type: "TextPositionSelector"; start: number; // more precisely: non-negative integer end: number; // more precisely: non-negative integer } @@ -92,7 +92,7 @@ export interface TextPositionSelector extends Selector { * @public */ export interface RangeSelector extends Selector { - type: 'RangeSelector'; + type: "RangeSelector"; startSelector: T; endSelector: T; } From 05e582d17a917dd594afebdd62919e29943651a7 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Fri, 13 Dec 2024 18:30:10 +0100 Subject: [PATCH 08/29] [skip ci] From c993d5661c1976291eb391f11019f3b4765cbee8 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 16 Dec 2024 09:51:08 +0100 Subject: [PATCH 09/29] set resourceCache to a dedicated file --- .../reader/redux/sagas/resourceCache.ts | 89 +++++++++++++++++++ src/renderer/reader/redux/sagas/search.ts | 68 +------------- 2 files changed, 93 insertions(+), 64 deletions(-) create mode 100644 src/renderer/reader/redux/sagas/resourceCache.ts 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 c2ca3fbfb..cebf5f7e8 100644 --- a/src/renderer/reader/redux/sagas/search.ts +++ b/src/renderer/reader/redux/sagas/search.ts @@ -10,7 +10,6 @@ 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 { 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"; @@ -22,14 +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, readerLocalActionSetResourceToCache } from "../actions"; + +import { readerLocalActionHighlights, readerLocalActionSearch } from "../actions"; import { IHighlightHandlerState } from "readium-desktop/common/redux/states/renderer/highlight"; import debounce from "debounce"; -import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { getResourceCache } from "./resourceCache"; const handleLinkLocatorDebounced = debounce(handleLinkLocator, 200); @@ -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: 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 put(readerLocalActionSetResourceToCache.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) { From e4268a011e063908e94ff8adcca3aa8d75eea747 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 16 Dec 2024 14:43:32 +0100 Subject: [PATCH 10/29] apache-annotator remove .js extension and fix type linting --- src/third_party/apache-annotator/dom/css.ts | 6 +++--- .../apache-annotator/dom/highlight-text.ts | 4 ++-- src/third_party/apache-annotator/dom/index.ts | 10 +++++----- .../apache-annotator/dom/normalize-range.ts | 2 +- .../apache-annotator/dom/range/cartesian.ts | 2 +- .../apache-annotator/dom/range/index.ts | 2 +- .../apache-annotator/dom/range/match.ts | 8 ++++---- .../apache-annotator/dom/text-node-chunker.ts | 18 +++++++++--------- .../dom/text-position/describe.ts | 10 +++++----- .../dom/text-position/index.ts | 4 ++-- .../dom/text-position/match.ts | 6 +++--- .../dom/text-quote/describe.ts | 10 +++++----- .../apache-annotator/dom/text-quote/index.ts | 4 ++-- .../apache-annotator/dom/text-quote/match.ts | 6 +++--- .../apache-annotator/dom/to-range.ts | 2 +- .../apache-annotator/selector/index.ts | 6 +++--- .../apache-annotator/selector/refinable.ts | 2 +- .../selector/text/code-point-seeker.ts | 4 ++-- .../selector/text/describe-text-position.ts | 8 ++++---- .../selector/text/describe-text-quote.ts | 12 ++++++------ .../apache-annotator/selector/text/index.ts | 10 +++++----- .../selector/text/match-text-position.ts | 8 ++++---- .../selector/text/match-text-quote.ts | 4 ++-- .../apache-annotator/selector/text/seeker.ts | 4 ++-- 24 files changed, 76 insertions(+), 76 deletions(-) diff --git a/src/third_party/apache-annotator/dom/css.ts b/src/third_party/apache-annotator/dom/css.ts index 2ed1d1946..d75a75d8d 100644 --- a/src/third_party/apache-annotator/dom/css.ts +++ b/src/third_party/apache-annotator/dom/css.ts @@ -23,9 +23,9 @@ import { uniqueCssSelector as finder } from "@r2-navigator-js/electron/renderer/common/cssselector2-3"; -import type { CssSelector, Matcher } from "../selector/types.js"; -import { ownerDocument } from "./owner-document.js"; -import { toRange } from "./to-range.js"; +import type { CssSelector, Matcher } from "../selector/types"; +import { ownerDocument } from "./owner-document"; +import { toRange } from "./to-range"; /** * Find the elements corresponding to the given {@link diff --git a/src/third_party/apache-annotator/dom/highlight-text.ts b/src/third_party/apache-annotator/dom/highlight-text.ts index b2ca976eb..176eb8e8e 100644 --- a/src/third_party/apache-annotator/dom/highlight-text.ts +++ b/src/third_party/apache-annotator/dom/highlight-text.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ownerDocument } from "./owner-document.js"; -import { toRange } from "./to-range.js"; +import { ownerDocument } from "./owner-document"; +import { toRange } from "./to-range"; /** * Wrap each text node in a given Node or Range with a `` or other diff --git a/src/third_party/apache-annotator/dom/index.ts b/src/third_party/apache-annotator/dom/index.ts index 94bb009a4..7dcf3a948 100644 --- a/src/third_party/apache-annotator/dom/index.ts +++ b/src/third_party/apache-annotator/dom/index.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./css.js"; -export * from "./range/index.js"; -export * from "./text-quote/index.js"; -export * from "./text-position/index.js"; -export * from "./highlight-text.js"; +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 index 9199917a3..4d5dfcaa8 100644 --- a/src/third_party/apache-annotator/dom/normalize-range.ts +++ b/src/third_party/apache-annotator/dom/normalize-range.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ownerDocument } from "./owner-document.js"; +import { ownerDocument } from "./owner-document"; /** * TextRange is a Range that guarantees to always have Text nodes as its start diff --git a/src/third_party/apache-annotator/dom/range/cartesian.ts b/src/third_party/apache-annotator/dom/range/cartesian.ts index b2fee07cb..1a1fe1451 100644 --- a/src/third_party/apache-annotator/dom/range/cartesian.ts +++ b/src/third_party/apache-annotator/dom/range/cartesian.ts @@ -68,7 +68,7 @@ export async function* cartesian( } // Append the new value to the log. - // @ts-expect-error + // @ts-expect-error thorium quick hack-typing const { value } = result.value; logs[index].push(value); diff --git a/src/third_party/apache-annotator/dom/range/index.ts b/src/third_party/apache-annotator/dom/range/index.ts index 66ca90af8..9b6304344 100644 --- a/src/third_party/apache-annotator/dom/range/index.ts +++ b/src/third_party/apache-annotator/dom/range/index.ts @@ -21,4 +21,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./match.js"; +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 index dc72587e0..44d1b33d6 100644 --- a/src/third_party/apache-annotator/dom/range/match.ts +++ b/src/third_party/apache-annotator/dom/range/match.ts @@ -25,11 +25,11 @@ import type { Matcher, RangeSelector, Selector, -} from "../../selector/types.js"; +} from "../../selector/types"; -import { ownerDocument } from "../owner-document.js"; -import { toRange } from "../to-range.js"; -import { cartesian } from "./cartesian.js"; +import { ownerDocument } from "../owner-document"; +import { toRange } from "../to-range"; +import { cartesian } from "./cartesian"; /** * Find the range(s) corresponding to the given {@link RangeSelector}. diff --git a/src/third_party/apache-annotator/dom/text-node-chunker.ts b/src/third_party/apache-annotator/dom/text-node-chunker.ts index 8e30a92bc..f2d317358 100644 --- a/src/third_party/apache-annotator/dom/text-node-chunker.ts +++ b/src/third_party/apache-annotator/dom/text-node-chunker.ts @@ -21,10 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Chunk, Chunker, ChunkRange } from '../selector/text/chunker.js'; -import { normalizeRange } from './normalize-range.js'; -import { ownerDocument } from './owner-document.js'; -import { toRange } from './to-range.js'; +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; @@ -34,7 +34,7 @@ export interface PartialTextNode extends Chunk { export class EmptyScopeError extends TypeError { constructor(message?: string) { - super(message || 'Scope contains no text nodes.'); + super(message || "Scope contains no text nodes."); } } @@ -42,12 +42,12 @@ export class OutOfScopeError extends TypeError { constructor(message?: string) { super( message || - 'Cannot convert node to chunk, as it falls outside of chunker’s scope.', + "Cannot convert node to chunk, as it falls outside of chunker’s scope.", ); } } -//@ts-expect-error +//@ts-expect-error thorium quick hack typing export class TextNodeChunker implements Chunker { private scope: Range; private iter: NodeIterator; @@ -84,7 +84,7 @@ export class TextNodeChunker implements Chunker { }; } -//@ts-expect-error +//@ts-expect-error thorium quick hack typing rangeToChunkRange(range: Range): ChunkRange { range = range.cloneRange(); @@ -105,7 +105,7 @@ export class TextNodeChunker implements Chunker { return { startChunk, startIndex, endChunk, endIndex }; } -//@ts-expect-error +//@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 diff --git a/src/third_party/apache-annotator/dom/text-position/describe.ts b/src/third_party/apache-annotator/dom/text-position/describe.ts index f320a1185..32e4867c8 100644 --- a/src/third_party/apache-annotator/dom/text-position/describe.ts +++ b/src/third_party/apache-annotator/dom/text-position/describe.ts @@ -21,11 +21,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextPositionSelector } from "../../selector/types.js"; -import { describeTextPosition as abstractDescribeTextPosition } from "../../selector/text/describe-text-position.js"; -import { ownerDocument } from "../owner-document.js"; -import { TextNodeChunker } from "../text-node-chunker.js"; -import { toRange } from "../to-range.js"; +import type { TextPositionSelector } from "../../selector/types"; +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"; /** * Returns a {@link TextPositionSelector} that points at the target text within diff --git a/src/third_party/apache-annotator/dom/text-position/index.ts b/src/third_party/apache-annotator/dom/text-position/index.ts index fb2c93d59..574545b26 100644 --- a/src/third_party/apache-annotator/dom/text-position/index.ts +++ b/src/third_party/apache-annotator/dom/text-position/index.ts @@ -21,5 +21,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./describe.js"; -export * from "./match.js"; +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 index f6f059492..30c6c0656 100644 --- a/src/third_party/apache-annotator/dom/text-position/match.ts +++ b/src/third_party/apache-annotator/dom/text-position/match.ts @@ -21,9 +21,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, TextPositionSelector } from "../../selector/types.js"; -import { textPositionSelectorMatcher as abstractTextPositionSelectorMatcher } from "../../selector/text/match-text-position.js"; -import { TextNodeChunker } from "../text-node-chunker.js"; +import type { Matcher, TextPositionSelector } from "../../selector/types"; +import { textPositionSelectorMatcher as abstractTextPositionSelectorMatcher } from "../../selector/text/match-text-position"; +import { TextNodeChunker } from "../text-node-chunker"; /** * Find the range of text corresponding to the given {@link diff --git a/src/third_party/apache-annotator/dom/text-quote/describe.ts b/src/third_party/apache-annotator/dom/text-quote/describe.ts index afacff58f..abbc82395 100644 --- a/src/third_party/apache-annotator/dom/text-quote/describe.ts +++ b/src/third_party/apache-annotator/dom/text-quote/describe.ts @@ -23,11 +23,11 @@ import type { TextQuoteSelector, -} from "../../selector/types.js"; -import { describeTextQuote as abstractDescribeTextQuote, type DescribeTextQuoteOptions } from "../../selector/text/describe-text-quote.js"; -import { ownerDocument } from "../owner-document.js"; -import { TextNodeChunker } from "../text-node-chunker.js"; -import { toRange } from "../to-range.js"; +} from "../../selector/types"; +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"; /** * Returns a {@link TextQuoteSelector} that unambiguously describes the given diff --git a/src/third_party/apache-annotator/dom/text-quote/index.ts b/src/third_party/apache-annotator/dom/text-quote/index.ts index fb2c93d59..574545b26 100644 --- a/src/third_party/apache-annotator/dom/text-quote/index.ts +++ b/src/third_party/apache-annotator/dom/text-quote/index.ts @@ -21,5 +21,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./describe.js"; -export * from "./match.js"; +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 index bd120ac9b..41cdef851 100644 --- a/src/third_party/apache-annotator/dom/text-quote/match.ts +++ b/src/third_party/apache-annotator/dom/text-quote/match.ts @@ -21,9 +21,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, TextQuoteSelector } from "../../selector/types.js"; -import { textQuoteSelectorMatcher as abstractTextQuoteSelectorMatcher } from "../../selector/text/match-text-quote.js"; -import { TextNodeChunker, EmptyScopeError } from "../text-node-chunker.js"; +import type { Matcher, TextQuoteSelector } from "../../selector/types"; +import { textQuoteSelectorMatcher as abstractTextQuoteSelectorMatcher } from "../../selector/text/match-text-quote"; +import { TextNodeChunker, EmptyScopeError } from "../text-node-chunker"; /** * Find occurrences in a text matching the given {@link diff --git a/src/third_party/apache-annotator/dom/to-range.ts b/src/third_party/apache-annotator/dom/to-range.ts index 7d9aa2005..1bf10209d 100644 --- a/src/third_party/apache-annotator/dom/to-range.ts +++ b/src/third_party/apache-annotator/dom/to-range.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ownerDocument } from "./owner-document.js"; +import { ownerDocument } from "./owner-document"; /** * Returns a range that exactly selects the contents of the given node. diff --git a/src/third_party/apache-annotator/selector/index.ts b/src/third_party/apache-annotator/selector/index.ts index 31f8dae62..4b2346c7b 100644 --- a/src/third_party/apache-annotator/selector/index.ts +++ b/src/third_party/apache-annotator/selector/index.ts @@ -28,6 +28,6 @@ export type { RangeSelector, TextPositionSelector, TextQuoteSelector, -} from "./types.js"; -export * from "./text/index.js"; -export * from "./refinable.js"; +} 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 index 1b96b6408..afd2c1645 100644 --- a/src/third_party/apache-annotator/selector/refinable.ts +++ b/src/third_party/apache-annotator/selector/refinable.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, Selector } from "./types.js"; +import type { Matcher, Selector } from "./types"; /** * A Refinable selector can have the `refinedBy` attribute, whose value must be 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 index a4f9565d4..06314423d 100644 --- a/src/third_party/apache-annotator/selector/text/code-point-seeker.ts +++ b/src/third_party/apache-annotator/selector/text/code-point-seeker.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Chunk } from "./chunker.js"; -import type { Seeker } from "./seeker.js"; +import type { Chunk } from "./chunker"; +import type { Seeker } from "./seeker"; /** * Seeks through text counting Unicode *code points* instead of *code units*. 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 index 9d8b43721..05132712a 100644 --- a/src/third_party/apache-annotator/selector/text/describe-text-position.ts +++ b/src/third_party/apache-annotator/selector/text/describe-text-position.ts @@ -21,10 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextPositionSelector } from "../types.js"; -import type { Chunk, Chunker, ChunkRange } from "./chunker.js"; -import { CodePointSeeker } from "./code-point-seeker.js"; -import { TextSeeker } from "./seeker.js"; +import type { TextPositionSelector } from "../types"; +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 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 index 63bedfd5c..f58484edf 100644 --- a/src/third_party/apache-annotator/selector/text/describe-text-quote.ts +++ b/src/third_party/apache-annotator/selector/text/describe-text-quote.ts @@ -21,12 +21,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextQuoteSelector } from "../types.js"; -import type { Chunk, Chunker, ChunkRange } from "./chunker.js"; -import { chunkRangeEquals } from "./chunker.js"; -import { textQuoteSelectorMatcher } from "./match-text-quote.js"; -import type { RelativeSeeker } from "./seeker.js"; -import { TextSeeker } from "./seeker.js"; +import type { TextQuoteSelector } from "../types"; +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 diff --git a/src/third_party/apache-annotator/selector/text/index.ts b/src/third_party/apache-annotator/selector/text/index.ts index 6f5d4961b..5a64517c9 100644 --- a/src/third_party/apache-annotator/selector/text/index.ts +++ b/src/third_party/apache-annotator/selector/text/index.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./describe-text-quote.js"; -export * from "./match-text-quote.js"; -export * from "./describe-text-position.js"; -export * from "./match-text-position.js"; -export * from "./chunker.js"; +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 index 6891f13d9..edad547ba 100644 --- a/src/third_party/apache-annotator/selector/text/match-text-position.ts +++ b/src/third_party/apache-annotator/selector/text/match-text-position.ts @@ -21,10 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextPositionSelector } from "../types.js"; -import type { Chunk, ChunkRange, Chunker } from "./chunker.js"; -import { CodePointSeeker } from "./code-point-seeker.js"; -import { TextSeeker } from "./seeker.js"; +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}. 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 index a2e02cc2c..2e3680cc0 100644 --- a/src/third_party/apache-annotator/selector/text/match-text-quote.ts +++ b/src/third_party/apache-annotator/selector/text/match-text-quote.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextQuoteSelector } from "../types.js"; -import type { Chunk, Chunker, ChunkRange } from "./chunker.js"; +import type { TextQuoteSelector } from "../types"; +import type { Chunk, Chunker, ChunkRange } from "./chunker"; /** * Find occurrences in a text matching the given {@link TextQuoteSelector}. diff --git a/src/third_party/apache-annotator/selector/text/seeker.ts b/src/third_party/apache-annotator/selector/text/seeker.ts index f7301abd0..11f24a64a 100644 --- a/src/third_party/apache-annotator/selector/text/seeker.ts +++ b/src/third_party/apache-annotator/selector/text/seeker.ts @@ -21,8 +21,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Chunk, Chunker } from "./chunker.js"; -import { chunkEquals } from "./chunker.js"; +import type { Chunk, Chunker } from "./chunker"; +import { chunkEquals } from "./chunker"; const E_END = "Iterator exhausted before seek ended."; From 0304776beed802c6f12b173782f378d10c9704f8 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 16 Dec 2024 14:45:21 +0100 Subject: [PATCH 11/29] export annotation [skip ci] --- .../annotation/annotationModel.type.ts | 39 +++++---- src/common/readium/annotation/converter.ts | 77 ++++++++++++---- src/renderer/reader/components/ReaderMenu.tsx | 8 +- .../redux/actions/exportAnnotationSet.ts | 34 ++++++++ src/renderer/reader/redux/actions/index.ts | 2 + src/renderer/reader/redux/sagas/index.ts | 3 + .../reader/redux/sagas/shareAnnotationSet.ts | 87 +++++++++++++++++++ src/utils/search/search.ts | 32 ++----- src/utils/xmlDom.ts | 47 ++++++++++ 9 files changed, 266 insertions(+), 63 deletions(-) create mode 100644 src/renderer/reader/redux/actions/exportAnnotationSet.ts create mode 100644 src/renderer/reader/redux/sagas/shareAnnotationSet.ts create mode 100644 src/utils/xmlDom.ts diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts index d0589a4f1..ebaf5d0c6 100644 --- a/src/common/readium/annotation/annotationModel.type.ts +++ b/src/common/readium/annotation/annotationModel.type.ts @@ -8,6 +8,17 @@ import Ajv from "ajv"; import addFormats from "ajv-formats"; +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; @@ -39,13 +50,18 @@ export interface IReadiumAnnotation { page?: string; }; selector: Array<( - ITextQuoteSelector - | ITextPositionSelector - | IFragmentSelector + ISelector + // ITextQuoteSelector + // | ITextPositionSelector + // | IFragmentSelector )>; }; } +export interface ISelector { + type: string; +} + /** { "type": "TextPositionSelector", @@ -53,7 +69,7 @@ export interface IReadiumAnnotation { "end": 55 } */ -export interface ITextPositionSelector { +export interface ITextPositionSelector extends ISelector { type: "TextPositionSelector", start: number, end: number, @@ -64,7 +80,7 @@ export function isTextPositionSelector(a: any): a is ITextPositionSelector { && typeof a.end === "number"; } -export interface ITextQuoteSelector { +export interface ITextQuoteSelector extends ISelector { type: "TextQuoteSelector"; exact: string; prefix: string; @@ -111,7 +127,7 @@ export function isTextQuoteSelector(a: any): a is ITextQuoteSelector { // && typeof a.endOffset === "number"; // } -export interface IFragmentSelector { +export interface IFragmentSelector extends ISelector { type: "FragmentSelector"; conformsTo: string; value: string; @@ -147,17 +163,6 @@ interface About { "dc:date"?: string; } -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 const readiumAnnotationSetSchema = { "$schema": "http://json-schema.org/draft-07/schema#", "title": "IReadiumAnnotationSet", diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index a6e9d0978..dd399e76d 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -5,16 +5,69 @@ // that can be found in the LICENSE file exposed on Github (readium) in the project repository. // ==LICENSE-END== -import { IReadiumAnnotation, IReadiumAnnotationSet } from "./annotationModel.type"; +import * as debug_ from "debug"; + +import { IReadiumAnnotation, IReadiumAnnotationSet, ISelector } 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 { describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom"; +import { convertRangeInfo } from "r2-navigator-js/dist/es8-es2017/src/electron/renderer/webview/selection"; + + +// Logger +const debug = debug_("readium-desktop:common:readium:annotation:converter"); + +export type IAnnotationStateWithICacheDocument = IAnnotationState & { __cacheDocument?: ICacheDocument | undefined }; + +export async function convertAnnotationStateToSelector(annotationWithCacheDoc: IAnnotationStateWithICacheDocument): Promise { + + const selector: ISelector[] = []; + + const {__cacheDocument, ...annotation} = annotationWithCacheDoc; + + const xmlDom = getDocumentFromICacheDocument(__cacheDocument); + if (!xmlDom) { + return []; + } + + + const { locatorExtended } = annotation; + const { selectionInfo } = locatorExtended; + const { rangeInfo } = selectionInfo; + + + const range = convertRangeInfo(xmlDom, rangeInfo); + debug(range); -// import { describeTextPosition } from "readium-desktop/third_party/apache-annotator/dom/text-position"; + // describeTextPosition() + const selectorTextPosition = await describeTextPosition(range); + debug("TextPositionSelector : ", selectorTextPosition); + selector.push(selectorTextPosition); -export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotationState): IReadiumAnnotation { + // describeTextQuote() + const selectorTextQuote = await describeTextQuote(range); + debug("TextQuoteSelector : ", selectorTextQuote); + selector.push(selectorTextQuote); + + + + // convert IRangeInfo serializer to DomRnage memory + // https://github.com/readium/r2-navigator-js/blob/a08126622ac87e04200a178cc438fd7e1b256c52/src/electron/renderer/webview/selection.ts#L600C17-L600C33 + + // convert domRange memory to serialization + // https://github.com/readium/r2-navigator-js/blob/a08126622ac87e04200a178cc438fd7e1b256c52/src/electron/renderer/webview/selection.ts#L342 + + // Next TODO: CFI !?! + + return selector; +} + +export async function convertAnnotationStateToReadiumAnnotation(annotation: IAnnotationStateWithICacheDocument): Promise { const { uuid, color, locatorExtended: def, tags, drawType, comment, creator, created, modified } = annotation; const { locator, headings, epubPage/*, selectionInfo*/ } = def; @@ -25,19 +78,7 @@ export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotatio const highlight: IReadiumAnnotation["body"]["highlight"] = drawType === "solid_background" ? "solid" : drawType; - const selector: IReadiumAnnotation["target"]["selector"] = []; - - // if (highlightRaw && afterRaw && beforeRaw) { - // selector.push({ - // type: "TextQuoteSelector", - // exact: highlightRaw, - // prefix: beforeRaw, - // suffix: afterRaw, - // }); - // } - - - // need to convert locator to Range and convert it with apache annotator to TextQuote and TextPosition, and in a second time : CssSelectorWithTextPositionSelector ! + const selector = await convertAnnotationStateToSelector(annotation); return { "@context": "http://www.w3.org/ns/anno.jsonld", @@ -67,7 +108,7 @@ export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotatio }; } -export function convertAnnotationListToReadiumAnnotationSet(annotationArray: IAnnotationState[], publicationView: PublicationView, label?: string): IReadiumAnnotationSet { +export async function convertAnnotationStateArrayToReadiumAnnotationSet(annotationArray: IAnnotationStateWithICacheDocument[], publicationView: PublicationView, label?: string): Promise { const currentDate = new Date(); const dateString: string = currentDate.toISOString(); @@ -92,6 +133,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))), }; } diff --git a/src/renderer/reader/components/ReaderMenu.tsx b/src/renderer/reader/components/ReaderMenu.tsx index 82abaf831..a949ae017 100644 --- a/src/renderer/reader/components/ReaderMenu.tsx +++ b/src/renderer/reader/components/ReaderMenu.tsx @@ -1146,8 +1146,12 @@ const AnnotationList: React.FC<{ annotationUUIDFocused: string, resetAnnotationU label = label.replace(/^_+|_+$/g, ""); // leading and trailing underscore label = label.replace(/^\./, ""); // remove dot start label = label.toLowerCase(); - const contents = convertAnnotationListToReadiumAnnotationSet(annotations, publicationView, title); - downloadAnnotationJSON(contents, label); + + // TODO: dispatch an action to launch export saga routine: init/get resource, iterator on each annotations => w3cAnnotations, return IReadiumAnnotationSet + // const contents = convertAnnotationListToReadiumAnnotationSet(annotations, publicationView, title); + // downloadAnnotationJSON(contents, label); + + dispatch(readerLocalActionExportAnnotationSet.build(annotations, publicationView, label)); }} className={stylesButtons.button_primary_blue}> {__("catalog.export")} diff --git a/src/renderer/reader/redux/actions/exportAnnotationSet.ts b/src/renderer/reader/redux/actions/exportAnnotationSet.ts new file mode 100644 index 000000000..31e0821fd --- /dev/null +++ b/src/renderer/reader/redux/actions/exportAnnotationSet.ts @@ -0,0 +1,34 @@ +// ==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 } from "readium-desktop/common/models/redux"; +import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +import { PublicationView } from "readium-desktop/common/views/publication"; + +export const ID = "READER_EXPORT_ANNOTATION_SET"; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Payload { + annotationArray: IAnnotationState[]; + publicationView: PublicationView; + label?: string; +} + +export function build(annotationArray: IAnnotationState[], publicationView: PublicationView, label?: string): + Action { + + 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 4575cb2f5..f65a5a2ff 100644 --- a/src/renderer/reader/redux/actions/index.ts +++ b/src/renderer/reader/redux/actions/index.ts @@ -16,8 +16,10 @@ 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, diff --git a/src/renderer/reader/redux/sagas/index.ts b/src/renderer/reader/redux/sagas/index.ts index ad6108f7d..9fd5fdddb 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"; @@ -106,6 +107,8 @@ export function* rootSaga() { search.saga(), annotation.saga(), + + shareAnnotationSet.saga(), takeSpawnEvery( setTheme.ID, diff --git a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts new file mode 100644 index 000000000..f101cc049 --- /dev/null +++ b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts @@ -0,0 +1,87 @@ +// ==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 } 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 { MiniLocatorExtended } from "readium-desktop/common/redux/states/locatorInitialState"; +import { IReaderRootState } from "readium-desktop/common/redux/states/renderer/readerRootState"; +import { convertAnnotationStateArrayToReadiumAnnotationSet, IAnnotationStateWithICacheDocument } from "readium-desktop/common/readium/annotation/converter"; +import { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; + +// Logger +const debug = debug_("readium-desktop:renderer:reader:redux:sagas:shareAnnotationSet"); +debug("_"); + + + +const getCacheDocumentFromLocator = (cacheDocumentArray: ICacheDocument[], locator: MiniLocatorExtended): ICacheDocument => { + + for (const cacheDoc of cacheDocumentArray) { + if (cacheDoc.href && cacheDoc.href === locator?.locator?.href) { + return cacheDoc; + } + } + + return undefined; +}; + +function* exportAnnotationSet(): SagaGenerator { + + const exportAnnotationSetAction = yield* takeTyped(readerLocalActionExportAnnotationSet.build); + const { payload: { annotationArray, publicationView, label } } = exportAnnotationSetAction; + + yield* callTyped(getResourceCache); + + debug("exportAnnotationSet just started !"); + debug(annotationArray); + debug(typeof publicationView); + debug("label", label); + // yield delay(10000); + + const cacheDocuments = yield* selectTyped((state: IReaderRootState) => state.resourceCache); + + const annotationsWithCacheDocumentArray: IAnnotationStateWithICacheDocument[] = []; + + for (const anno of annotationArray) { + annotationsWithCacheDocumentArray.push({ + ...anno, + __cacheDocument: getCacheDocumentFromLocator(cacheDocuments, anno.locatorExtended), + }); + } + + const readiumAnnotationSet = yield* callTyped(() => convertAnnotationStateArrayToReadiumAnnotationSet(annotationsWithCacheDocumentArray, publicationView, label)); + + 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("readerStart", e), + ), + ]); + diff --git a/src/utils/search/search.ts b/src/utils/search/search.ts index a6c78f37d..a66c726b9 100644 --- a/src/utils/search/search.ts +++ b/src/utils/search/search.ts @@ -8,13 +8,11 @@ // import { JSDOM } from "jsdom"; // import * as xmldom from "@xmldom/xmldom"; -import { removeUTF8BOM } from "readium-desktop/common/utils/bom"; - -import { ContentType } from "../contentType"; import { searchDocDomSeek } from "./searchWithDomSeek"; import { IRangeInfo } from "@r2-navigator-js/electron/common/selection"; import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { getDocumentFromICacheDocument } from "../xmlDom"; export interface ISearchResult { rangeInfo: IRangeInfo; @@ -33,24 +31,6 @@ export interface ISearchResult { export async function search(searchInput: string, data: ICacheDocument): Promise { - if (!data.xml) { - return []; - } - if (!window.DOMParser) { - console.log("NOT RENDERER PROCESS???! (DOMParser for search)"); - return []; - } - - // 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 = 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 { // const isRenderer = typeof window !== undefined; // && typeof process === undefined; // const xmlDom = isRenderer ? (new DOMParser()).parseFromString( @@ -61,11 +41,11 @@ export async function search(searchInput: string, data: ICacheDocument): Promise // 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/xmlDom.ts b/src/utils/xmlDom.ts new file mode 100644 index 000000000..62c6a112d --- /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"; + +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 = 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; +} + From 1c85520af0469dfcaf9ed7d9197f9d8b5306f916 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 16 Dec 2024 18:16:33 +0100 Subject: [PATCH 12/29] import annotation --- src/common/readium/annotation/converter.ts | 91 ++++++++++++++++--- src/main/redux/sagas/annotation.ts | 1 + src/renderer/reader/components/ReaderMenu.tsx | 28 +++--- src/renderer/reader/redux/sagas/index.ts | 14 --- .../reader/redux/sagas/shareAnnotationSet.ts | 69 +++++++++++--- 5 files changed, 150 insertions(+), 53 deletions(-) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index dd399e76d..564af6942 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -7,7 +7,7 @@ import * as debug_ from "debug"; -import { IReadiumAnnotation, IReadiumAnnotationSet, ISelector } from "./annotationModel.type"; +import { IReadiumAnnotation, IReadiumAnnotationSet, ISelector, isTextPositionSelector } 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"; @@ -15,13 +15,86 @@ import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/a 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 { describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom"; -import { convertRangeInfo } from "r2-navigator-js/dist/es8-es2017/src/electron/renderer/webview/selection"; +import { createTextPositionSelectorMatcher, describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom"; +import { convertRange, convertRangeInfo } from "r2-navigator-js/dist/es8-es2017/src/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/dist/es8-es2017/src/electron/common/selection"; // Logger const debug = debug_("readium-desktop:common:readium:annotation:converter"); + +export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnnotation["target"], cacheDoc: ICacheDocument): Promise { + + const xmlDom = getDocumentFromICacheDocument(cacheDoc); + if (!xmlDom) { + return undefined; + } + + // const textQuoteSelector = target.selector.find(isTextQuoteSelector); + const textPositionSelector = target.selector.find(isTextPositionSelector); + // const fragmentSelectorArray = target.selector.filter(isFragmentSelector); + // const cfiFragmentSelector = fragmentSelectorArray.find(isCFIFragmentSelector); + + const root = xmlDom.body.ownerDocument.documentElement; + + if (textPositionSelector) { + + debug("TextPositionSelector found !!", JSON.stringify(textPositionSelector)); + const textPositionMatches = createTextPositionSelectorMatcher(textPositionSelector)(root); + // const textPositionMatches = textPositionSelectorMatcher(textPositionSelector)(xmlDom.body); + const matchRange = (await textPositionMatches.next()).value; + if (matchRange) { + + const tuple = convertRange(matchRange, (element) => finder(element, xmlDom, {root}), () => "", () => ""); + const rangeInfo = tuple[0]; + const textInfo = tuple[1]; + + + 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: {}, + }, + selectionInfo, + + audioPlaybackInfo: undefined, + paginationInfo: undefined, + selectionIsNew: undefined, + docInfo: undefined, + epubPage: undefined, + epubPageID: undefined, + headings: undefined, + secondWebViewHref: undefined, + }; + + return locatorExtended; + } + } else { + + debug("No selector found !!", JSON.stringify(target.selector, null, 4)); + } + + return undefined; +} + export type IAnnotationStateWithICacheDocument = IAnnotationState & { __cacheDocument?: ICacheDocument | undefined }; export async function convertAnnotationStateToSelector(annotationWithCacheDoc: IAnnotationStateWithICacheDocument): Promise { @@ -45,23 +118,15 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I debug(range); // describeTextPosition() - const selectorTextPosition = await describeTextPosition(range); + const selectorTextPosition = await describeTextPosition(range, xmlDom.body); debug("TextPositionSelector : ", selectorTextPosition); selector.push(selectorTextPosition); // describeTextQuote() - const selectorTextQuote = await describeTextQuote(range); + const selectorTextQuote = await describeTextQuote(range, xmlDom.body); debug("TextQuoteSelector : ", selectorTextQuote); selector.push(selectorTextQuote); - - - // convert IRangeInfo serializer to DomRnage memory - // https://github.com/readium/r2-navigator-js/blob/a08126622ac87e04200a178cc438fd7e1b256c52/src/electron/renderer/webview/selection.ts#L600C17-L600C33 - - // convert domRange memory to serialization - // https://github.com/readium/r2-navigator-js/blob/a08126622ac87e04200a178cc438fd7e1b256c52/src/electron/renderer/webview/selection.ts#L342 - // Next TODO: CFI !?! return selector; diff --git a/src/main/redux/sagas/annotation.ts b/src/main/redux/sagas/annotation.ts index 5e7610fe1..a6c105f02 100644 --- a/src/main/redux/sagas/annotation.ts +++ b/src/main/redux/sagas/annotation.ts @@ -165,6 +165,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct debug(`for ${uuid} a CFI selector is available (${JSON.stringify(cfiFragmentSelector, null, 4)})`); } + // check if thorium selector available if (!(textQuoteSelector || textPositionSelector)) { debug(`for ${uuid} no selector available (TextQuote/TextPosition)`); continue; diff --git a/src/renderer/reader/components/ReaderMenu.tsx b/src/renderer/reader/components/ReaderMenu.tsx index a949ae017..d0715ae3b 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 { IReadiumAnnotationSet } 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"; @@ -667,18 +665,6 @@ const AnnotationCard: React.FC<{ timestamp: number, annotation: IAnnotationState const selectionIsSet = (a: Selection): a is Set => typeof a === "object"; const MAX_MATCHES_PER_PAGE = 5; -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); -}; - const userNumber: Record = {}; const AnnotationList: React.FC<{ annotationUUIDFocused: string, resetAnnotationUUID: () => void, doFocus: number, popoverBoundary: HTMLDivElement, advancedAnnotationsOnChange: () => void, quickAnnotationsOnChange: () => void, marginAnnotationsOnChange: () => void, hideAnnotationOnChange: () => void, serialAnnotator: boolean } & Pick> = (props) => { @@ -1114,6 +1100,18 @@ const AnnotationList: React.FC<{ annotationUUIDFocused: string, resetAnnotationU + {/* !!!! */} + {/* !!!! */} + {/* !!!! */} + {/* !!!! */} + {/* !!!! */} + {/* !!!! */} + {/* TODO: Form submission not connected !?! , is it useful to have form element here !?! */} + {/* !!!! */} + {/* !!!! */} + {/* !!!! */} + {/* !!!! */} + {/* !!!! */}
{ diff --git a/src/renderer/reader/redux/sagas/index.ts b/src/renderer/reader/redux/sagas/index.ts index 9fd5fdddb..1f29374d0 100644 --- a/src/renderer/reader/redux/sagas/index.ts +++ b/src/renderer/reader/redux/sagas/index.ts @@ -79,20 +79,6 @@ export function* rootSaga() { yield call(winInit.render); - - - // if annotationImportQueue not empty then - // start the import process - - // when push action dispatched start the import process - // with cache push action trigger the import process but not in // in sequence - // new annotations in queue => - // shift it => - // process it => - // wait 100ms => - // look if new data in the queue => - // if no new note available, wait new push action - yield all([ i18n.saga(), ipc.saga(), diff --git a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts index f101cc049..d8fa2fbd4 100644 --- a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts +++ b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts @@ -6,17 +6,18 @@ // ==LICENSE-END= import * as debug_ from "debug"; -import { select as selectTyped, take as takeTyped, all as allTyped, call as callTyped, SagaGenerator } from "typed-redux-saga/macro"; +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 { MiniLocatorExtended } from "readium-desktop/common/redux/states/locatorInitialState"; import { IReaderRootState } from "readium-desktop/common/redux/states/renderer/readerRootState"; -import { convertAnnotationStateArrayToReadiumAnnotationSet, IAnnotationStateWithICacheDocument } from "readium-desktop/common/readium/annotation/converter"; +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"); @@ -24,10 +25,10 @@ debug("_"); -const getCacheDocumentFromLocator = (cacheDocumentArray: ICacheDocument[], locator: MiniLocatorExtended): ICacheDocument => { +const getCacheDocumentFromLocator = (cacheDocumentArray: ICacheDocument[], hrefSource: string): ICacheDocument => { for (const cacheDoc of cacheDocumentArray) { - if (cacheDoc.href && cacheDoc.href === locator?.locator?.href) { + if (hrefSource && cacheDoc.href && cacheDoc.href === hrefSource) { return cacheDoc; } } @@ -35,6 +36,47 @@ const getCacheDocumentFromLocator = (cacheDocumentArray: ICacheDocument[], locat 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.shift(); + + debug("annotationState:", JSON.stringify(annotationState, null, 4)); + debug("SelectorTarget from AnnotationState", JSON.stringify(target, null, 4)); + + yield* putTyped(annotationActions.shiftFromAnnotationImportQueue.build()); + + 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; + } + + yield* putTyped(readerActions.annotation.push.build(annotationStateFormated)); + yield* delayTyped(100); + + 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); @@ -43,10 +85,9 @@ function* exportAnnotationSet(): SagaGenerator { yield* callTyped(getResourceCache); debug("exportAnnotationSet just started !"); - debug(annotationArray); - debug(typeof publicationView); - debug("label", label); - // yield delay(10000); + debug("AnnotationArray: ", annotationArray); + debug("PubView ok?", typeof publicationView); + debug("label:", label); const cacheDocuments = yield* selectTyped((state: IReaderRootState) => state.resourceCache); @@ -55,12 +96,14 @@ function* exportAnnotationSet(): SagaGenerator { for (const anno of annotationArray) { annotationsWithCacheDocumentArray.push({ ...anno, - __cacheDocument: getCacheDocumentFromLocator(cacheDocuments, anno.locatorExtended), + __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); @@ -81,7 +124,11 @@ export const saga = () => allTyped([ spawnLeading( exportAnnotationSet, - (e) => console.error("readerStart", e), + (e) => console.error("exportAnnotationSet", e), + ), + spawnLeading( + importAnnotationSet, + (e) => console.error("importAnnotationSet", e), ), ]); From cef265c7602423a917dd0b6877c5c3d1b132e5bb Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 16 Dec 2024 18:40:32 +0100 Subject: [PATCH 13/29] add textQuote in import process [skip ci] --- src/common/readium/annotation/converter.ts | 100 +++++++++++++++------ 1 file changed, 74 insertions(+), 26 deletions(-) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index 564af6942..eb4421d2b 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -7,7 +7,7 @@ import * as debug_ from "debug"; -import { IReadiumAnnotation, IReadiumAnnotationSet, ISelector, isTextPositionSelector } from "./annotationModel.type"; +import { IReadiumAnnotation, IReadiumAnnotationSet, ISelector, isTextPositionSelector, isTextQuoteSelector } 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"; @@ -15,12 +15,12 @@ import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/a 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 { createTextPositionSelectorMatcher, describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom"; -import { convertRange, convertRangeInfo } from "r2-navigator-js/dist/es8-es2017/src/electron/renderer/webview/selection"; +import { createTextPositionSelectorMatcher, createTextQuoteSelectorMatcher, describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom"; +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/dist/es8-es2017/src/electron/common/selection"; - +import { ISelectionInfo } from "@r2-navigator-js/electron/common/selection"; +import * as ramda from "ramda"; // Logger const debug = debug_("readium-desktop:common:readium:annotation:converter"); @@ -33,18 +33,20 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn return undefined; } - // const textQuoteSelector = target.selector.find(isTextQuoteSelector); + const textQuoteSelector = target.selector.find(isTextQuoteSelector); const textPositionSelector = target.selector.find(isTextPositionSelector); // const fragmentSelectorArray = target.selector.filter(isFragmentSelector); // const cfiFragmentSelector = fragmentSelectorArray.find(isCFIFragmentSelector); const root = xmlDom.body.ownerDocument.documentElement; + const selectionInfoFound: ISelectionInfo[] = []; + + let selectorFound = false; if (textPositionSelector) { debug("TextPositionSelector found !!", JSON.stringify(textPositionSelector)); const textPositionMatches = createTextPositionSelectorMatcher(textPositionSelector)(root); - // const textPositionMatches = textPositionSelectorMatcher(textPositionSelector)(xmlDom.body); const matchRange = (await textPositionMatches.next()).value; if (matchRange) { @@ -67,32 +69,78 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn rawAfter: textInfo.rawAfter, }; debug("SelectionInfo generated:", JSON.stringify(selectionInfo, null, 4)); + selectorFound = true; + selectionInfoFound.push(selectionInfo); + } + } - const locatorExtended: MiniLocatorExtended = { - locator: { - href: cacheDoc.href, - locations: {}, - }, - selectionInfo, - - audioPlaybackInfo: undefined, - paginationInfo: undefined, - selectionIsNew: undefined, - docInfo: undefined, - epubPage: undefined, - epubPageID: undefined, - headings: undefined, - secondWebViewHref: undefined, - }; + if (textQuoteSelector) { + debug("TextQuoteSelector found !!", JSON.stringify(textQuoteSelector)); + + const textQuoteMatches = createTextQuoteSelectorMatcher(textQuoteSelector)(root); + const matchRange = (await textQuoteMatches.next()).value; + if (matchRange) { + + const tuple = convertRange(matchRange, (element) => finder(element, xmlDom, {root}), () => "", () => ""); + const rangeInfo = tuple[0]; + const textInfo = tuple[1]; + + + const selectionInfo: ISelectionInfo = { + textFragment: undefined, + + rangeInfo, + + cleanBefore: textInfo.cleanBefore, + cleanText: textInfo.cleanText, + cleanAfter: textInfo.cleanAfter, - return locatorExtended; + rawBefore: textInfo.rawBefore, + rawText: textInfo.rawText, + rawAfter: textInfo.rawAfter, + }; + debug("SelectionInfo generated:", JSON.stringify(selectionInfo, null, 4)); + selectorFound = true; + selectionInfoFound.push(selectionInfo); } - } else { + } + + if (!selectorFound) { debug("No selector found !!", JSON.stringify(target.selector, null, 4)); + return undefined; } - return undefined; + let selectionInfoReduce = selectionInfoFound.reduce((pv, cv) => ramda.equals(pv, cv) ? cv : undefined, selectionInfoFound[0]); + if (selectionInfoReduce) { + debug("selectionInfo Found and equal to each selectors"); + } else { + debug("selection Info not equal to each selector !!!"); + selectionInfoReduce = selectionInfoFound[0]; // we assume the first is good; + } + + if (!selectionInfoReduce) { + return undefined; + } + + const locatorExtended: MiniLocatorExtended = { + locator: { + href: cacheDoc.href, + locations: {}, + }, + selectionInfo: selectionInfoReduce, + + 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 }; From 87bdedc58db961314537fcd2e2678550b5cc31ca Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Tue, 17 Dec 2024 16:07:39 +0100 Subject: [PATCH 14/29] add reader lock protection for importAnnotationSet function TODO: dispatch annotationQueue shift action across reader --- .../reader/redux/sagas/shareAnnotationSet.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts index d8fa2fbd4..1ed2b0bb7 100644 --- a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts +++ b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts @@ -51,7 +51,6 @@ export function* importAnnotationSet(): SagaGenerator { debug("annotationState:", JSON.stringify(annotationState, null, 4)); debug("SelectorTarget from AnnotationState", JSON.stringify(target, null, 4)); - yield* putTyped(annotationActions.shiftFromAnnotationImportQueue.build()); const { source } = target; const cacheDocuments = yield* selectTyped((state: IReaderRootState) => state.resourceCache); @@ -66,9 +65,17 @@ export function* importAnnotationSet(): SagaGenerator { continue; } + // push new annotation to reader and then synced with main db process yield* putTyped(readerActions.annotation.push.build(annotationStateFormated)); + + // wait to push new annotation before shift it from annotation queue + // not atomic : if the reader is closing during this import process it can forget data + yield* putTyped(annotationActions.shiftFromAnnotationImportQueue.build()); + + // 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); } @@ -127,7 +134,23 @@ export const saga = () => (e) => console.error("exportAnnotationSet", e), ), spawnLeading( - importAnnotationSet, + 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), ), ]); From 4245dc78b49b44b1ffe88a06adfc04d774803610 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Wed, 18 Dec 2024 10:52:59 +0100 Subject: [PATCH 15/29] fix: add actionAcrossRenderer to dispatch action from renderer to main-process and broadcasted to every other browserWindow opened ANNOTATION_SHIFT_FROM_ANNOTATION_IMPORT_QUEUE need this mechanism --- src/common/models/sync.ts | 7 +++++++ .../actions/annotation/shiftFromAnnotationImportQueue.ts | 5 +++-- src/main/redux/middleware/sync.ts | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) 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/redux/actions/annotation/shiftFromAnnotationImportQueue.ts b/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts index 61df62e15..7bed9e560 100644 --- a/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts +++ b/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts @@ -5,17 +5,18 @@ // that can be found in the LICENSE file exposed on Github (readium) in the project repository. // ==LICENSE-END== -import { Action } from "readium-desktop/common/models/redux"; +import { ActionAcrossRenderer } from "readium-desktop/common/models/sync"; export const ID = "ANNOTATION_SHIFT_FROM_ANNOTATION_IMPORT_QUEUE"; export interface Payload { } -export function build(): Action { +export function build(): ActionAcrossRenderer { return { type: ID, payload: { }, + sendActionAcrossRenderer: true, }; } build.toString = () => ID; // Redux StringableActionCreator diff --git a/src/main/redux/middleware/sync.ts b/src/main/redux/middleware/sync.ts index 8275553e3..083119367 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, @@ -152,6 +152,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 ) ) { From c2844100cd14c63521645912e82916568c24809a Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Wed, 18 Dec 2024 10:54:13 +0100 Subject: [PATCH 16/29] fixes importQueue shift first elem immutability --- src/renderer/reader/redux/sagas/shareAnnotationSet.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts index 1ed2b0bb7..69cf62178 100644 --- a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts +++ b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts @@ -46,7 +46,10 @@ export function* importAnnotationSet(): SagaGenerator { while (importQueue.length) { // start import routine - const { target, ...annotationState } = importQueue.shift(); + 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)); @@ -68,10 +71,6 @@ export function* importAnnotationSet(): SagaGenerator { // push new annotation to reader and then synced with main db process yield* putTyped(readerActions.annotation.push.build(annotationStateFormated)); - // wait to push new annotation before shift it from annotation queue - // not atomic : if the reader is closing during this import process it can forget data - yield* putTyped(annotationActions.shiftFromAnnotationImportQueue.build()); - // wait 100ms to not overload event-loop yield* delayTyped(100); From 6a7bc53ed87c1d34f2b54ebc608b9ccebff54da8 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Wed, 18 Dec 2024 11:04:52 +0100 Subject: [PATCH 17/29] remove unused addAnnotationToReaderPublication action --- .../addAnnotationToReaderPublication.ts | 30 ---------------- src/main/redux/actions/win/registry/index.ts | 2 -- .../redux/reducers/win/registry/reader.ts | 34 ------------------- 3 files changed, 66 deletions(-) delete mode 100644 src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts diff --git a/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts b/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts deleted file mode 100644 index 3e43437a8..000000000 --- a/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts +++ /dev/null @@ -1,30 +0,0 @@ -// // ==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 } from "readium-desktop/common/models/redux"; -// import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; - -// export const ID = "WIN_REGISTRY_REGISTER_ADD_ANNOTATION"; - -// export interface Payload { -// publicationIdentifier: string; -// annotations: IAnnotationState[]; -// } - -// export function build(publicationIdentifier: string, annotations: IAnnotationState[]): -// Action { - -// return { -// type: ID, -// payload: { -// publicationIdentifier, -// annotations, -// }, -// }; -// } -// build.toString = () => ID; // Redux StringableActionCreator -// export type TAction = ReturnType; diff --git a/src/main/redux/actions/win/registry/index.ts b/src/main/redux/actions/win/registry/index.ts index 19a859fc4..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/reducers/win/registry/reader.ts b/src/main/redux/reducers/win/registry/reader.ts index 0a240ab15..e69664855 100644 --- a/src/main/redux/reducers/win/registry/reader.ts +++ b/src/main/redux/reducers/win/registry/reader.ts @@ -17,7 +17,6 @@ function winRegistryReaderReducer_( state: IDictWinRegistryReaderState = initialState, action: winActions.registry.registerReaderPublication.TAction | winActions.registry.unregisterReaderPublication.TAction, - // | winActions.registry.addAnnotationToReaderPublication.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; } From 69f4c91f7973dc7c297c7830c8d081cdd877f8a9 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Wed, 18 Dec 2024 11:23:26 +0100 Subject: [PATCH 18/29] lint and change xmlDom xhtml root to document.body @danielweck need your advice about this ! --- src/common/readium/annotation/converter.ts | 9 ++++--- src/main/redux/middleware/sync.ts | 2 -- src/renderer/reader/components/ReaderMenu.tsx | 25 +++---------------- 3 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index eb4421d2b..b05516d2e 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -38,7 +38,8 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn // const fragmentSelectorArray = target.selector.filter(isFragmentSelector); // const cfiFragmentSelector = fragmentSelectorArray.find(isCFIFragmentSelector); - const root = xmlDom.body.ownerDocument.documentElement; + // TODO: @danielweck is it ok ? + const root = xmlDom.body; const selectionInfoFound: ISelectionInfo[] = []; @@ -156,6 +157,8 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I return []; } + // TODO: @danielweck is it ok ? + const root = xmlDom.body; const { locatorExtended } = annotation; const { selectionInfo } = locatorExtended; @@ -166,12 +169,12 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I debug(range); // describeTextPosition() - const selectorTextPosition = await describeTextPosition(range, xmlDom.body); + const selectorTextPosition = await describeTextPosition(range, root); debug("TextPositionSelector : ", selectorTextPosition); selector.push(selectorTextPosition); // describeTextQuote() - const selectorTextQuote = await describeTextQuote(range, xmlDom.body); + const selectorTextQuote = await describeTextQuote(range, root); debug("TextQuoteSelector : ", selectorTextQuote); selector.push(selectorTextQuote); diff --git a/src/main/redux/middleware/sync.ts b/src/main/redux/middleware/sync.ts index 083119367..1175c56aa 100644 --- a/src/main/redux/middleware/sync.ts +++ b/src/main/redux/middleware/sync.ts @@ -89,8 +89,6 @@ const SYNCHRONIZABLE_ACTIONS: string[] = [ annotationActions.pushToAnnotationImportQueue.ID, - - // TODO: shift dispatch from one reader do not dispatch it to other reader !?! need to check this issue before merge request annotationActions.shiftFromAnnotationImportQueue.ID, readerActions.setTheLock.ID, diff --git a/src/renderer/reader/components/ReaderMenu.tsx b/src/renderer/reader/components/ReaderMenu.tsx index d0715ae3b..f1a1915c3 100644 --- a/src/renderer/reader/components/ReaderMenu.tsx +++ b/src/renderer/reader/components/ReaderMenu.tsx @@ -1100,23 +1100,8 @@ const AnnotationList: React.FC<{ annotationUUIDFocused: string, resetAnnotationU - {/* !!!! */} - {/* !!!! */} - {/* !!!! */} - {/* !!!! */} - {/* !!!! */} - {/* !!!! */} - {/* TODO: Form submission not connected !?! , is it useful to have form element here !?! */} - {/* !!!! */} - {/* !!!! */} - {/* !!!! */} - {/* !!!! */} - {/* !!!! */} - { - e.preventDefault(); - }} >

{__("reader.annotations.annotationsExport.description")}

@@ -1129,7 +1114,7 @@ const AnnotationList: React.FC<{ annotationUUIDFocused: string, resetAnnotationU className="R2_CSS_CLASS__FORCE_NO_FOCUS_OUTLINE" />
- - +
From cd89bf0f324f32c84e08d4f88502cff4c9f35369 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Wed, 18 Dec 2024 15:38:54 +0100 Subject: [PATCH 19/29] when annotation import add locatorExtended.locations info (rangeInfo and CssSelector) --- src/common/readium/annotation/converter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index b05516d2e..f714c6323 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -127,7 +127,10 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn const locatorExtended: MiniLocatorExtended = { locator: { href: cacheDoc.href, - locations: {}, + locations: { + cssSelector: selectionInfoReduce.rangeInfo.startContainerElementCssSelector, + rangeInfo: selectionInfoReduce.rangeInfo, + }, }, selectionInfo: selectionInfoReduce, From 7cf31faf8240bf3feaeb7f84e096d8abb5c618db Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Fri, 20 Dec 2024 17:29:15 +0100 Subject: [PATCH 20/29] fix: add cssSelector and progressionSelector, and apply it to makeRefinable from apache-annotator TODO: need to define an heuristic of selector priority/importance --- .../annotation/annotationModel.type.ts | 30 ++- src/common/readium/annotation/converter.ts | 240 +++++++++++------- src/third_party/apache-annotator/dom/css.ts | 3 +- .../dom/text-position/describe.ts | 4 +- .../dom/text-position/match.ts | 5 +- .../dom/text-quote/describe.ts | 6 +- .../apache-annotator/dom/text-quote/match.ts | 5 +- .../apache-annotator/selector/refinable.ts | 9 +- .../selector/text/describe-text-position.ts | 4 +- .../selector/text/describe-text-quote.ts | 6 +- .../selector/text/match-text-quote.ts | 4 +- 11 files changed, 188 insertions(+), 128 deletions(-) diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts index ebaf5d0c6..b35837343 100644 --- a/src/common/readium/annotation/annotationModel.type.ts +++ b/src/common/readium/annotation/annotationModel.type.ts @@ -58,8 +58,9 @@ export interface IReadiumAnnotation { }; } -export interface ISelector { +export interface ISelector { type: string; + refinedBy?: T; } /** @@ -93,16 +94,23 @@ export function isTextQuoteSelector(a: any): a is ITextQuoteSelector { && typeof a.suffix === "string"; } -// not used anymore -// not an official w3c annotation selector -// export interface IProgressionSelector { -// type: "ProgressionSelector"; -// value: number; -// } -// export function isProgressionSelector(a: any): a is IProgressionSelector { -// return typeof a === "object" && a.type === "ProgressionSelector" -// && typeof a.value === "number"; -// } +export interface IProgressionSelector extends ISelector { + type: "ProgressionSelector"; + value: number; +} +export function isProgressionSelector(a: any): a is IProgressionSelector { + return typeof a === "object" && a.type === "ProgressionSelector" + && typeof a.value === "number"; +} + +export interface ICssSelector extends ISelector { + type: "CssSelector"; + value: string; +} +export function isCssSelector(a: any): a is ICssSelector { + return typeof a === "object" && a.type === "CssSelector" + && typeof a.value === "string"; +} // not used anymore // internal DOMRange selector not shared across annotation selector diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index f714c6323..6ae3a56ba 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -7,7 +7,7 @@ import * as debug_ from "debug"; -import { IReadiumAnnotation, IReadiumAnnotationSet, ISelector, isTextPositionSelector, isTextQuoteSelector } from "./annotationModel.type"; +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"; @@ -15,17 +15,16 @@ import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/a 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 { createTextPositionSelectorMatcher, createTextQuoteSelectorMatcher, describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom"; -import { convertRange, convertRangeInfo } from "@r2-navigator-js/electron/renderer/webview/selection"; +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, normalizeRange } 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"; -import * as ramda from "ramda"; // Logger const debug = debug_("readium-desktop:common:readium:annotation:converter"); - export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnnotation["target"], cacheDoc: ICacheDocument): Promise { const xmlDom = getDocumentFromICacheDocument(cacheDoc); @@ -33,106 +32,114 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn return undefined; } - const textQuoteSelector = target.selector.find(isTextQuoteSelector); - const textPositionSelector = target.selector.find(isTextPositionSelector); - // const fragmentSelectorArray = target.selector.filter(isFragmentSelector); - // const cfiFragmentSelector = fragmentSelectorArray.find(isCFIFragmentSelector); - - // TODO: @danielweck is it ok ? const root = xmlDom.body; - const selectionInfoFound: ISelectionInfo[] = []; - - let selectorFound = false; - if (textPositionSelector) { - - debug("TextPositionSelector found !!", JSON.stringify(textPositionSelector)); - const textPositionMatches = createTextPositionSelectorMatcher(textPositionSelector)(root); - const matchRange = (await textPositionMatches.next()).value; - if (matchRange) { - - const tuple = convertRange(matchRange, (element) => finder(element, xmlDom, {root}), () => "", () => ""); - const rangeInfo = tuple[0]; - const textInfo = tuple[1]; - + 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]; + + if (!innerCreateMatcher) { + + // no matcher for this selector + debug("no matcher for this selector:", selector.type); + return undefined; + } - const selectionInfo: ISelectionInfo = { - textFragment: undefined, + return innerCreateMatcher(selector as never); + }); - rangeInfo, + const ranges: Range[] = []; + const pushToRangeArray: (rangeOrElement: Range | Element) => void = (rangeOrElement) => { + let range: Range = undefined; - cleanBefore: textInfo.cleanBefore, - cleanText: textInfo.cleanText, - cleanAfter: textInfo.cleanAfter, + if (rangeOrElement instanceof Element) { + range = document.createRange(); + range.selectNode(rangeOrElement); + } else { + range = rangeOrElement; + } - rawBefore: textInfo.rawBefore, - rawText: textInfo.rawText, - rawAfter: textInfo.rawAfter, - }; - debug("SelectionInfo generated:", JSON.stringify(selectionInfo, null, 4)); - selectorFound = true; - selectionInfoFound.push(selectionInfo); + ranges.push(range); + }; + { + const matchAll = createMatcher(textQuoteSelector); + for await (const rangeOrElement of matchAll(root)) { + pushToRangeArray(rangeOrElement); + } + } + { + const matchAll = createMatcher(textPositionSelector); + for await (const rangeOrElement of matchAll(root)) { + pushToRangeArray(rangeOrElement); } } - - if (textQuoteSelector) { - debug("TextQuoteSelector found !!", JSON.stringify(textQuoteSelector)); - - const textQuoteMatches = createTextQuoteSelectorMatcher(textQuoteSelector)(root); - const matchRange = (await textQuoteMatches.next()).value; - if (matchRange) { - - const tuple = convertRange(matchRange, (element) => finder(element, xmlDom, {root}), () => "", () => ""); - const rangeInfo = tuple[0]; - const textInfo = tuple[1]; - - - 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)); - selectorFound = true; - selectionInfoFound.push(selectionInfo); + { + const matchAll = createMatcher(cssSelector); + for await (const rangeOrElement of matchAll(root)) { + pushToRangeArray(rangeOrElement); } - } - - if (!selectorFound) { + if (!ranges.length) { debug("No selector found !!", JSON.stringify(target.selector, null, 4)); return undefined; } + debug(`${ranges.length} range(s) found !!!`); - let selectionInfoReduce = selectionInfoFound.reduce((pv, cv) => ramda.equals(pv, cv) ? cv : undefined, selectionInfoFound[0]); - if (selectionInfoReduce) { - debug("selectionInfo Found and equal to each selectors"); - } else { - debug("selection Info not equal to each selector !!!"); - selectionInfoReduce = selectionInfoFound[0]; // we assume the first is good; - } + const convertedRangeArray: ReturnType[] = []; - if (!selectionInfoReduce) { + for (const range of ranges) { + const tuple = convertRange(range, (element) => finder(element, xmlDom, {root}), () => "", () => ""); + if (tuple && tuple.length === 2) { + convertedRangeArray.push(tuple); + } + } + 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: selectionInfoReduce.rangeInfo.startContainerElementCssSelector, - rangeInfo: selectionInfoReduce.rangeInfo, + cssSelector: selectionInfo.rangeInfo.startContainerElementCssSelector, + rangeInfo: selectionInfo.rangeInfo, + progression: progressionValue, }, }, - selectionInfo: selectionInfoReduce, + selectionInfo: selectionInfo, audioPlaybackInfo: undefined, paginationInfo: undefined, @@ -147,11 +154,34 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn return locatorExtended; } -export type IAnnotationStateWithICacheDocument = IAnnotationState & { __cacheDocument?: ICacheDocument | undefined }; +export type IAnnotationStateWithICacheDocument = IAnnotationState & { __cacheDocument?: ICacheDocument | undefined }; + +const describeCssSelectorWithTextPosition = async (range: Range, document: Document, root: HTMLElement): Promise | undefined> => { + const rangeNormalize = normalizeRange(range); // from r2-nav and not from third-party/apache-annotator + + const commonAncestorHTMLElement = + (rangeNormalize.commonAncestorContainer && rangeNormalize.commonAncestorContainer.nodeType === Node.ELEMENT_NODE) + ? rangeNormalize.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( + rangeNormalize, + commonAncestorHTMLElement, + ), + }; +}; -export async function convertAnnotationStateToSelector(annotationWithCacheDoc: IAnnotationStateWithICacheDocument): Promise { +export async function convertAnnotationStateToSelector(annotationWithCacheDoc: IAnnotationStateWithICacheDocument, isLcp: boolean): Promise { - const selector: ISelector[] = []; + const selector: ISelector[] = []; const {__cacheDocument, ...annotation} = annotationWithCacheDoc; @@ -160,33 +190,52 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I return []; } - // TODO: @danielweck is it ok ? + const document = xmlDom; const root = xmlDom.body; const { locatorExtended } = annotation; - const { selectionInfo } = locatorExtended; + const { selectionInfo, locator } = locatorExtended; + const { locations } = locator; + const { progression } = locations; const { rangeInfo } = selectionInfo; - const range = convertRangeInfo(xmlDom, rangeInfo); - debug(range); + debug("Dump range memory found:", range); + + // 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); - // describeTextQuote() - const selectorTextQuote = await describeTextQuote(range, root); - debug("TextQuoteSelector : ", selectorTextQuote); - selector.push(selectorTextQuote); + 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): Promise { +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; @@ -197,7 +246,7 @@ export async function convertAnnotationStateToReadiumAnnotation(annotation: IAnn const highlight: IReadiumAnnotation["body"]["highlight"] = drawType === "solid_background" ? "solid" : drawType; - const selector = await convertAnnotationStateToSelector(annotation); + const selector = await convertAnnotationStateToSelector(annotation, isLcp); return { "@context": "http://www.w3.org/ns/anno.jsonld", @@ -231,6 +280,7 @@ export async function convertAnnotationStateArrayToReadiumAnnotationSet(annotati const currentDate = new Date(); const dateString: string = currentDate.toISOString(); + const isLcp = !!publicationView.lcp; return { "@context": "http://www.w3.org/ns/anno.jsonld", @@ -252,6 +302,6 @@ export async function convertAnnotationStateArrayToReadiumAnnotationSet(annotati "dc:creator": publicationView.authors || [], "dc:date": publicationView.publishedAt || "", }, - items: await Promise.all((annotationArray || []).map(async (v) => await convertAnnotationStateToReadiumAnnotation(v))), + items: await Promise.all((annotationArray || []).map(async (v) => await convertAnnotationStateToReadiumAnnotation(v, isLcp))), }; } diff --git a/src/third_party/apache-annotator/dom/css.ts b/src/third_party/apache-annotator/dom/css.ts index d75a75d8d..90b59c71d 100644 --- a/src/third_party/apache-annotator/dom/css.ts +++ b/src/third_party/apache-annotator/dom/css.ts @@ -26,6 +26,7 @@ import { uniqueCssSelector as finder } from "@r2-navigator-js/electron/renderer/ 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 @@ -73,7 +74,7 @@ import { toRange } from "./to-range"; * @public */ export function createCssSelectorMatcher( - selector: CssSelector, + selector: ICssSelector, ): Matcher { return async function* matchAll(scope) { scope = toRange(scope); diff --git a/src/third_party/apache-annotator/dom/text-position/describe.ts b/src/third_party/apache-annotator/dom/text-position/describe.ts index 32e4867c8..33afad171 100644 --- a/src/third_party/apache-annotator/dom/text-position/describe.ts +++ b/src/third_party/apache-annotator/dom/text-position/describe.ts @@ -21,11 +21,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextPositionSelector } from "../../selector/types"; 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 @@ -61,7 +61,7 @@ import { toRange } from "../to-range"; export async function describeTextPosition( range: Range, scope?: Node | Range, -): Promise { +): Promise { scope = toRange(scope ?? ownerDocument(range)); const textChunks = new TextNodeChunker(scope); diff --git a/src/third_party/apache-annotator/dom/text-position/match.ts b/src/third_party/apache-annotator/dom/text-position/match.ts index 30c6c0656..80c6b9866 100644 --- a/src/third_party/apache-annotator/dom/text-position/match.ts +++ b/src/third_party/apache-annotator/dom/text-position/match.ts @@ -21,9 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, TextPositionSelector } from "../../selector/types"; +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 @@ -55,7 +56,7 @@ import { TextNodeChunker } from "../text-node-chunker"; * @public */ export function createTextPositionSelectorMatcher( - selector: TextPositionSelector, + selector: ITextPositionSelector, ): Matcher { const abstractMatcher = abstractTextPositionSelectorMatcher(selector); diff --git a/src/third_party/apache-annotator/dom/text-quote/describe.ts b/src/third_party/apache-annotator/dom/text-quote/describe.ts index abbc82395..2c08eee92 100644 --- a/src/third_party/apache-annotator/dom/text-quote/describe.ts +++ b/src/third_party/apache-annotator/dom/text-quote/describe.ts @@ -21,13 +21,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - TextQuoteSelector, -} from "../../selector/types"; 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 @@ -67,7 +65,7 @@ export async function describeTextQuote( range: Range, scope?: Node | Range, options: DescribeTextQuoteOptions = {}, -): Promise { +): Promise { const scopeAsRange = toRange(scope ?? ownerDocument(range)); const chunker = new TextNodeChunker(scopeAsRange); diff --git a/src/third_party/apache-annotator/dom/text-quote/match.ts b/src/third_party/apache-annotator/dom/text-quote/match.ts index 41cdef851..7e575e4b3 100644 --- a/src/third_party/apache-annotator/dom/text-quote/match.ts +++ b/src/third_party/apache-annotator/dom/text-quote/match.ts @@ -21,9 +21,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Matcher, TextQuoteSelector } from "../../selector/types"; +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 @@ -69,7 +70,7 @@ import { TextNodeChunker, EmptyScopeError } from "../text-node-chunker"; * @public */ export function createTextQuoteSelectorMatcher( - selector: TextQuoteSelector, + selector: ITextQuoteSelector, ): Matcher { const abstractMatcher = abstractTextQuoteSelectorMatcher(selector); diff --git a/src/third_party/apache-annotator/selector/refinable.ts b/src/third_party/apache-annotator/selector/refinable.ts index afd2c1645..3d5f8b367 100644 --- a/src/third_party/apache-annotator/selector/refinable.ts +++ b/src/third_party/apache-annotator/selector/refinable.ts @@ -21,6 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ISelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; import type { Matcher, Selector } from "./types"; /** @@ -58,16 +59,16 @@ export type Refinable = T & { refinedBy?: Refinable }; * @public */ export function makeRefinable< - TSelector extends Selector, + 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: Refinable) => Matcher, -): (selector: Refinable) => Matcher { + matcherCreator: (selector: TSelector) => Matcher, +): (selector: TSelector) => Matcher { return function createMatcherWithRefinement( - sourceSelector: Refinable, + sourceSelector: TSelector, ): Matcher { const matcher = matcherCreator(sourceSelector); 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 index 05132712a..a8929de73 100644 --- a/src/third_party/apache-annotator/selector/text/describe-text-position.ts +++ b/src/third_party/apache-annotator/selector/text/describe-text-position.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextPositionSelector } from "../types"; +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"; @@ -48,7 +48,7 @@ import { TextSeeker } from "./seeker"; export async function describeTextPosition>( target: ChunkRange, scope: Chunker, -): Promise { +): Promise { const codeUnitSeeker = new TextSeeker(scope); const codePointSeeker = new CodePointSeeker(codeUnitSeeker); 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 index f58484edf..95aed9557 100644 --- a/src/third_party/apache-annotator/selector/text/describe-text-quote.ts +++ b/src/third_party/apache-annotator/selector/text/describe-text-quote.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextQuoteSelector } from "../types"; +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"; @@ -81,7 +81,7 @@ export async function describeTextQuote>( target: ChunkRange, scope: () => Chunker, options: DescribeTextQuoteOptions = {}, -): Promise { +): Promise { const { minimalContext = false, minimumQuoteLength = 0, @@ -156,7 +156,7 @@ export async function describeTextQuote>( // each unintended match we encounter, we extend the prefix or suffix to // ensure it will no longer match. while (true) { - const tentativeSelector: TextQuoteSelector = { + const tentativeSelector: ITextQuoteSelector = { type: "TextQuoteSelector", exact, prefix, 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 index 2e3680cc0..49a1158b3 100644 --- a/src/third_party/apache-annotator/selector/text/match-text-quote.ts +++ b/src/third_party/apache-annotator/selector/text/match-text-quote.ts @@ -21,7 +21,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { TextQuoteSelector } from "../types"; +import { ITextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; import type { Chunk, Chunker, ChunkRange } from "./chunker"; /** @@ -66,7 +66,7 @@ import type { Chunk, Chunker, ChunkRange } from "./chunker"; * @public */ export function textQuoteSelectorMatcher( - selector: TextQuoteSelector, + selector: ITextQuoteSelector, ): >( scope: Chunker, ) => AsyncGenerator, void, void> { From 7738eabc18c4c551fa7b46fda42f0f92fa739e1e Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 30 Dec 2024 13:48:07 +0100 Subject: [PATCH 21/29] fix: old annotation imported from updated annotation, now update the note and do not create a duplicate note to the list --- .../reader/redux/sagas/shareAnnotationSet.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts index 69cf62178..e818784c4 100644 --- a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts +++ b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts @@ -68,8 +68,16 @@ export function* importAnnotationSet(): SagaGenerator { continue; } - // push new annotation to reader and then synced with main db process - yield* putTyped(readerActions.annotation.push.build(annotationStateFormated)); + 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); From b3a39cdd98d08275b4202355b8ed17e6ce41e1c1 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 30 Dec 2024 13:48:23 +0100 Subject: [PATCH 22/29] fix: import dialog labels --- .../components/ImportAnnotationsDialog.tsx | 2 +- src/resources/locales/en.json | 16 +++++++++------- src/typings/en.translation-keys.d.ts | 2 +- src/typings/en.translation.d.ts | 16 +++++++++++----- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/renderer/common/components/ImportAnnotationsDialog.tsx b/src/renderer/common/components/ImportAnnotationsDialog.tsx index 491822f42..32bb83175 100644 --- a/src/renderer/common/components/ImportAnnotationsDialog.tsx +++ b/src/renderer/common/components/ImportAnnotationsDialog.tsx @@ -68,7 +68,7 @@ export const ImportAnnotationsDialog: React.FC{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/resources/locales/en.json b/src/resources/locales/en.json index bea212b75..64a998b3d 100644 --- a/src/resources/locales/en.json +++ b/src/resources/locales/en.json @@ -123,14 +123,16 @@ }, "dialog": { "annotations": { - "descAuthor": "of {{- author}}", - "descList": "{{- nb}} note(s) from {{- creator}} will be associated with {{- title}} {{- author}}", - "descNewer": "{{- nb}} newer versions of these notes are already associated with the publication.", - "descOlder": "{{- nb}} older versions of these notes are already associated with the publication.", - "descTitle": "Annotation Set Title: ", + "descAuthor": "by {{- author}}", + "descCreator": "written by", + "origin": "Origin: {{- title}}{{- author}}", + "descList": "{{- nb}} note(s) {{- creator}} will be imported to {{- title}} {{- author}}", + "descNewer": "{{- nb}} conflict(s): some notes associated with the publication are newer.", + "descOlder": "{{- nb}} conflict(s): some notes associated with the publication are older.", + "descTitle": "Annotation Set: ", "importAll": "Import all notes", - "importWithoutConflict": "Import notes without conflict", - "title": "Do you want to import these notes ?" + "importWithoutConflict": "Import conflict-free notes only", + "title": "Do you want to import these notes?" }, "cancel": "Cancel", "deleteAnnotations": "Delete annotations?", diff --git a/src/typings/en.translation-keys.d.ts b/src/typings/en.translation-keys.d.ts index 32eec422e..129107232 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.continueReadingAudioBooks" | "catalog.entry.continueReadingDivina" | "catalog.entry.continueReadingPdf" | "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.licenseSignatureDateInvalid" | "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.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.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" | "reader.tts.language" | "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.continueReadingAudioBooks" | "catalog.entry.continueReadingDivina" | "catalog.entry.continueReadingPdf" | "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.origin" | "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.licenseSignatureDateInvalid" | "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.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; \ No newline at end of file diff --git a/src/typings/en.translation.d.ts b/src/typings/en.translation.d.ts index 7c63f702e..9effd50e6 100644 --- a/src/typings/en.translation.d.ts +++ b/src/typings/en.translation.d.ts @@ -304,6 +304,8 @@ declare namespace typed_i18n { (_: "dialog", __?: {}): { readonly "annotations": { readonly "descAuthor": string, + readonly "descCreator": string, + readonly "origin": string, readonly "descList": string, readonly "descNewer": string, readonly "descOlder": string, @@ -325,6 +327,8 @@ declare namespace typed_i18n { }; (_: "dialog.annotations", __?: {}): { readonly "descAuthor": string, + readonly "descCreator": string, + readonly "origin": string, readonly "descList": string, readonly "descNewer": string, readonly "descOlder": string, @@ -334,6 +338,8 @@ declare namespace typed_i18n { readonly "title": string }; (_: "dialog.annotations.descAuthor", __?: {}): string; + (_: "dialog.annotations.descCreator", __?: {}): string; + (_: "dialog.annotations.origin", __?: {}): string; (_: "dialog.annotations.descList", __?: {}): string; (_: "dialog.annotations.descNewer", __?: {}): string; (_: "dialog.annotations.descOlder", __?: {}): string; @@ -934,6 +940,7 @@ declare namespace typed_i18n { readonly "tts": { readonly "activate": string, readonly "default": string, + readonly "language": string, readonly "next": string, readonly "pause": string, readonly "play": string, @@ -942,8 +949,7 @@ declare namespace typed_i18n { readonly "sentenceDetectDescription": string, readonly "speed": string, readonly "stop": string, - readonly "voice": string, - readonly "language": string + readonly "voice": string } }; (_: "reader.annotations", __?: {}): { @@ -1375,6 +1381,7 @@ declare namespace typed_i18n { (_: "reader.tts", __?: {}): { readonly "activate": string, readonly "default": string, + readonly "language": string, readonly "next": string, readonly "pause": string, readonly "play": string, @@ -1383,11 +1390,11 @@ declare namespace typed_i18n { readonly "sentenceDetectDescription": string, readonly "speed": string, readonly "stop": string, - readonly "voice": string, - readonly "language": string + readonly "voice": string }; (_: "reader.tts.activate", __?: {}): string; (_: "reader.tts.default", __?: {}): string; + (_: "reader.tts.language", __?: {}): string; (_: "reader.tts.next", __?: {}): string; (_: "reader.tts.pause", __?: {}): string; (_: "reader.tts.play", __?: {}): string; @@ -1397,7 +1404,6 @@ declare namespace typed_i18n { (_: "reader.tts.speed", __?: {}): string; (_: "reader.tts.stop", __?: {}): string; (_: "reader.tts.voice", __?: {}): string; - (_: "reader.tts.language", __?: {}): string; (_: "settings", __?: {}): { readonly "annotationCreator": { readonly "creator": string, From c9184f7176bcc9c61db96ffcf46c64d14aecc1d9 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 30 Dec 2024 14:12:35 +0100 Subject: [PATCH 23/29] fixes en/fr i18n annotation dialog --- src/resources/locales/ar.json | 2 ++ src/resources/locales/bg.json | 2 ++ src/resources/locales/ca.json | 2 ++ src/resources/locales/da.json | 2 ++ src/resources/locales/de.json | 2 ++ src/resources/locales/el.json | 2 ++ src/resources/locales/en.json | 6 +++--- src/resources/locales/es.json | 2 ++ src/resources/locales/eu.json | 2 ++ src/resources/locales/fi.json | 2 ++ src/resources/locales/fr.json | 18 ++++++++++-------- src/resources/locales/gl.json | 2 ++ src/resources/locales/hr.json | 2 ++ src/resources/locales/it.json | 2 ++ src/resources/locales/ja.json | 2 ++ src/resources/locales/ka.json | 2 ++ src/resources/locales/ko.json | 2 ++ src/resources/locales/lt.json | 2 ++ src/resources/locales/nl.json | 2 ++ src/resources/locales/pt-br.json | 2 ++ src/resources/locales/pt-pt.json | 2 ++ src/resources/locales/ru.json | 2 ++ src/resources/locales/sl.json | 2 ++ src/resources/locales/sv.json | 2 ++ src/resources/locales/zh-cn.json | 2 ++ src/resources/locales/zh-tw.json | 2 ++ 26 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/resources/locales/ar.json b/src/resources/locales/ar.json index c8cfae88f..362d5e365 100644 --- a/src/resources/locales/ar.json +++ b/src/resources/locales/ar.json @@ -124,12 +124,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 4a3538df1..4d8d5eb05 100644 --- a/src/resources/locales/bg.json +++ b/src/resources/locales/bg.json @@ -124,12 +124,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 b2e10b1e2..8fe4d65b7 100644 --- a/src/resources/locales/ca.json +++ b/src/resources/locales/ca.json @@ -124,12 +124,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Cancel·lar", diff --git a/src/resources/locales/da.json b/src/resources/locales/da.json index f74b015fa..bf58a6eaf 100644 --- a/src/resources/locales/da.json +++ b/src/resources/locales/da.json @@ -124,12 +124,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Annullér", diff --git a/src/resources/locales/de.json b/src/resources/locales/de.json index 422691edd..7be375f89 100644 --- a/src/resources/locales/de.json +++ b/src/resources/locales/de.json @@ -124,12 +124,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 2c958e1cf..95e8c3465 100644 --- a/src/resources/locales/el.json +++ b/src/resources/locales/el.json @@ -124,12 +124,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 64a998b3d..752cd6779 100644 --- a/src/resources/locales/en.json +++ b/src/resources/locales/en.json @@ -124,15 +124,15 @@ "dialog": { "annotations": { "descAuthor": "by {{- author}}", - "descCreator": "written by", - "origin": "Origin: {{- title}}{{- author}}", + "descCreator": "created by", "descList": "{{- nb}} note(s) {{- creator}} will be imported to {{- title}} {{- author}}", "descNewer": "{{- nb}} conflict(s): some notes associated with the publication are newer.", "descOlder": "{{- nb}} conflict(s): some notes associated with the publication are older.", "descTitle": "Annotation Set: ", "importAll": "Import all notes", "importWithoutConflict": "Import conflict-free notes only", - "title": "Do you want to import these notes?" + "origin": "Origin: {{- title}}{{- author}}", + "title": "Do you want to import all of these notes?" }, "cancel": "Cancel", "deleteAnnotations": "Delete annotations?", diff --git a/src/resources/locales/es.json b/src/resources/locales/es.json index 44a101d4d..b96518563 100644 --- a/src/resources/locales/es.json +++ b/src/resources/locales/es.json @@ -124,12 +124,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 ef23feb01..1ae35309c 100644 --- a/src/resources/locales/eu.json +++ b/src/resources/locales/eu.json @@ -124,12 +124,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 db52a78ee..d433526c6 100644 --- a/src/resources/locales/fi.json +++ b/src/resources/locales/fi.json @@ -124,12 +124,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 e27636107..2c13ec15e 100644 --- a/src/resources/locales/fr.json +++ b/src/resources/locales/fr.json @@ -123,14 +123,16 @@ }, "dialog": { "annotations": { - "descAuthor": "", - "descList": "", - "descNewer": "", - "descOlder": "", - "descTitle": "", - "importAll": "", - "importWithoutConflict": "", - "title": "" + "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": "", diff --git a/src/resources/locales/gl.json b/src/resources/locales/gl.json index 4fcc0cf80..dbf25c0a7 100644 --- a/src/resources/locales/gl.json +++ b/src/resources/locales/gl.json @@ -124,12 +124,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 da8df99b8..d4887b066 100644 --- a/src/resources/locales/hr.json +++ b/src/resources/locales/hr.json @@ -124,12 +124,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 032e1b5d2..8accff8d5 100644 --- a/src/resources/locales/it.json +++ b/src/resources/locales/it.json @@ -124,12 +124,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 6ee26b1dc..25f086a23 100644 --- a/src/resources/locales/ja.json +++ b/src/resources/locales/ja.json @@ -124,12 +124,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 2e861e251..841a93007 100644 --- a/src/resources/locales/ka.json +++ b/src/resources/locales/ka.json @@ -124,12 +124,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 517398cd7..4f16b6342 100644 --- a/src/resources/locales/ko.json +++ b/src/resources/locales/ko.json @@ -124,12 +124,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 384fbac5c..1bb2f4c48 100644 --- a/src/resources/locales/lt.json +++ b/src/resources/locales/lt.json @@ -124,12 +124,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Nutraukti", diff --git a/src/resources/locales/nl.json b/src/resources/locales/nl.json index 3a3fca273..43015cbc1 100644 --- a/src/resources/locales/nl.json +++ b/src/resources/locales/nl.json @@ -124,12 +124,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 cda6793f7..1b277db21 100644 --- a/src/resources/locales/pt-br.json +++ b/src/resources/locales/pt-br.json @@ -124,12 +124,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 22f400013..17de5d6b4 100644 --- a/src/resources/locales/pt-pt.json +++ b/src/resources/locales/pt-pt.json @@ -124,12 +124,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 aa6faaffe..4f6e651fa 100644 --- a/src/resources/locales/ru.json +++ b/src/resources/locales/ru.json @@ -124,12 +124,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 73eda6b77..fa4417c54 100644 --- a/src/resources/locales/sl.json +++ b/src/resources/locales/sl.json @@ -124,12 +124,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 a9d047e95..5e523a44e 100644 --- a/src/resources/locales/sv.json +++ b/src/resources/locales/sv.json @@ -124,12 +124,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 0d193592b..bb42ccbfb 100644 --- a/src/resources/locales/zh-cn.json +++ b/src/resources/locales/zh-cn.json @@ -124,12 +124,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 e1bb67b86..f11f4a009 100644 --- a/src/resources/locales/zh-tw.json +++ b/src/resources/locales/zh-tw.json @@ -124,12 +124,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "取消", From 8e9bb59f95d34d3882dbd347a42974bfe657a01a Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Mon, 30 Dec 2024 14:32:20 +0100 Subject: [PATCH 24/29] up: origin label annotation import modal --- src/renderer/common/components/ImportAnnotationsDialog.tsx | 6 +++++- src/resources/locales/en.json | 2 +- src/resources/locales/fr.json | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/renderer/common/components/ImportAnnotationsDialog.tsx b/src/renderer/common/components/ImportAnnotationsDialog.tsx index 32bb83175..7ba6d953e 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,6 +68,7 @@ export const ImportAnnotationsDialog: React.FC {__("dialog.annotations.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, diff --git a/src/resources/locales/en.json b/src/resources/locales/en.json index 752cd6779..a68179332 100644 --- a/src/resources/locales/en.json +++ b/src/resources/locales/en.json @@ -131,7 +131,7 @@ "descTitle": "Annotation Set: ", "importAll": "Import all notes", "importWithoutConflict": "Import conflict-free notes only", - "origin": "Origin: {{- title}}{{- author}}", + "origin": "Origin: {{- title}} {{- author}}", "title": "Do you want to import all of these notes?" }, "cancel": "Cancel", diff --git a/src/resources/locales/fr.json b/src/resources/locales/fr.json index 2c13ec15e..d129a5a72 100644 --- a/src/resources/locales/fr.json +++ b/src/resources/locales/fr.json @@ -128,10 +128,10 @@ "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 :", + "descTitle": "Titre de la liste de notes : ", "importAll": "Importer toutes les notes", "importWithoutConflict": "Importer les notes sans conflits", - "origin": "Source: {{- title}}{{- author}}", + "origin": "Source : {{- title}} {{- author}}", "title": "Voulez-vous importer toutes ces notes ?" }, "cancel": "Annuler", From fee85e7c5f2c24a41d8301a60af86d75fea335fc Mon Sep 17 00:00:00 2001 From: Daniel Weck Date: Wed, 29 Jan 2025 20:57:38 +0000 Subject: [PATCH 25/29] fix: guard against undefined/null TextQuoteSelector during annotation import (LCP protected EPUB?), also added DOM Range normalisation prior to convertRange() to guard against ranges created outside the navigator --- src/common/readium/annotation/converter.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index 6ae3a56ba..72c3c119b 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -26,7 +26,7 @@ import { ISelectionInfo } from "@r2-navigator-js/electron/common/selection"; const debug = debug_("readium-desktop:common:readium:annotation:converter"); export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnnotation["target"], cacheDoc: ICacheDocument): Promise { - + const xmlDom = getDocumentFromICacheDocument(cacheDoc); if (!xmlDom) { return undefined; @@ -50,7 +50,7 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn }[selector.type]; if (!innerCreateMatcher) { - + // no matcher for this selector debug("no matcher for this selector:", selector.type); return undefined; @@ -72,19 +72,19 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn ranges.push(range); }; - { + if (textQuoteSelector) { const matchAll = createMatcher(textQuoteSelector); for await (const rangeOrElement of matchAll(root)) { pushToRangeArray(rangeOrElement); - } + } } - { + if (textPositionSelector) { const matchAll = createMatcher(textPositionSelector); for await (const rangeOrElement of matchAll(root)) { pushToRangeArray(rangeOrElement); } } - { + if (cssSelector) { const matchAll = createMatcher(cssSelector); for await (const rangeOrElement of matchAll(root)) { pushToRangeArray(rangeOrElement); @@ -98,7 +98,11 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn const convertedRangeArray: ReturnType[] = []; - for (const range of ranges) { + for (const r of ranges) { + const range = normalizeRange(r); + if (range.collapsed) { + debug("RANGE COLLAPSED AFTER NORMALISE, skipping..."); + } const tuple = convertRange(range, (element) => finder(element, xmlDom, {root}), () => "", () => ""); if (tuple && tuple.length === 2) { convertedRangeArray.push(tuple); @@ -230,7 +234,7 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I debug("ProgressionSelector : ", progressionSelector); selector.push(progressionSelector); - // Next TODO: CFI !?! + // Next TODO: CFI !?! return selector; } From 71874f3ac01aa9601e27da0a8882f58966d98564 Mon Sep 17 00:00:00 2001 From: Daniel Weck Date: Wed, 29 Jan 2025 21:17:28 +0000 Subject: [PATCH 26/29] removed range normalisation as this can modify the original text boundaries, also fixed the collapsed range checks --- src/common/readium/annotation/converter.ts | 23 ++++++++++++++-------- src/utils/search/searchWithDomSeek.ts | 5 +++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index 72c3c119b..b3e6d9b5d 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -17,7 +17,7 @@ import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/res 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, normalizeRange } from "@r2-navigator-js/electron/renderer/webview/selection"; +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"; @@ -98,10 +98,11 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn const convertedRangeArray: ReturnType[] = []; - for (const r of ranges) { - const range = normalizeRange(r); + for (const range of ranges) { + // const range = normalizeRange(r); if (range.collapsed) { - debug("RANGE COLLAPSED AFTER NORMALISE, skipping..."); + debug("RANGE COLLAPSED :( skipping..."); + continue; } const tuple = convertRange(range, (element) => finder(element, xmlDom, {root}), () => "", () => ""); if (tuple && tuple.length === 2) { @@ -161,11 +162,12 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn export type IAnnotationStateWithICacheDocument = IAnnotationState & { __cacheDocument?: ICacheDocument | undefined }; const describeCssSelectorWithTextPosition = async (range: Range, document: Document, root: HTMLElement): Promise | undefined> => { - const rangeNormalize = normalizeRange(range); // from r2-nav and not from third-party/apache-annotator + // 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 = - (rangeNormalize.commonAncestorContainer && rangeNormalize.commonAncestorContainer.nodeType === Node.ELEMENT_NODE) - ? rangeNormalize.commonAncestorContainer as Element + (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; @@ -177,7 +179,7 @@ const describeCssSelectorWithTextPosition = async (range: Range, document: Docum type: "CssSelector", value: finder(commonAncestorHTMLElement, document, { root }), refinedBy: await describeTextPosition( - rangeNormalize, + range, commonAncestorHTMLElement, ), }; @@ -206,6 +208,11 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I 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) { diff --git a/src/utils/search/searchWithDomSeek.ts b/src/utils/search/searchWithDomSeek.ts index 435f93afd..7fae794ec 100644 --- a/src/utils/search/searchWithDomSeek.ts +++ b/src/utils/search/searchWithDomSeek.ts @@ -243,6 +243,11 @@ 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; + } const tuple = convertRange( range, (doc as any).getCssSelector, From d4a413f8cca9d673f829b6f407f95acd4df9ca17 Mon Sep 17 00:00:00 2001 From: Daniel Weck Date: Wed, 29 Jan 2025 21:36:01 +0000 Subject: [PATCH 27/29] added code comments about DOM Range document order (start/end points) ... basic normalisation from user selection that can be reversed => same selected text can yield different annotations --- src/common/readium/annotation/converter.ts | 3 +++ src/utils/search/searchWithDomSeek.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index b3e6d9b5d..dae3153e7 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -104,6 +104,8 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn debug("RANGE COLLAPSED :( skipping..."); continue; } + + // the range start/end is guaranteed in document order due to the text matchers above (forward tree walk) const tuple = convertRange(range, (element) => finder(element, xmlDom, {root}), () => "", () => ""); if (tuple && tuple.length === 2) { convertedRangeArray.push(tuple); @@ -205,6 +207,7 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I 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) const range = convertRangeInfo(xmlDom, rangeInfo); debug("Dump range memory found:", range); diff --git a/src/utils/search/searchWithDomSeek.ts b/src/utils/search/searchWithDomSeek.ts index 7fae794ec..fdf742ff7 100644 --- a/src/utils/search/searchWithDomSeek.ts +++ b/src/utils/search/searchWithDomSeek.ts @@ -248,6 +248,7 @@ export async function searchDocDomSeek(searchInput: string, doc: Document, href: 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) const tuple = convertRange( range, (doc as any).getCssSelector, From ea00f5440bf7080034bfdc243e7ae5256c52df57 Mon Sep 17 00:00:00 2001 From: Daniel Weck Date: Wed, 29 Jan 2025 21:51:13 +0000 Subject: [PATCH 28/29] [skip ci] added code comments about DOM Range document order contract --- src/common/readium/annotation/converter.ts | 4 ++-- src/utils/search/searchWithDomSeek.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index dae3153e7..631f3ef21 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -105,7 +105,7 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn continue; } - // the range start/end is guaranteed in document order due to the text matchers above (forward tree walk) + // 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); @@ -207,7 +207,7 @@ export async function convertAnnotationStateToSelector(annotationWithCacheDoc: I 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) + // 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); diff --git a/src/utils/search/searchWithDomSeek.ts b/src/utils/search/searchWithDomSeek.ts index fdf742ff7..57b3fe758 100644 --- a/src/utils/search/searchWithDomSeek.ts +++ b/src/utils/search/searchWithDomSeek.ts @@ -248,7 +248,7 @@ export async function searchDocDomSeek(searchInput: string, doc: Document, href: 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) + // 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, From 3abeaf03f8dc3ff55e5fe27564e2bf713e43b4de Mon Sep 17 00:00:00 2001 From: Daniel Weck Date: Fri, 31 Jan 2025 13:09:23 +0000 Subject: [PATCH 29/29] English localisation improvements --- .../components/ImportAnnotationsDialog.tsx | 4 +- src/renderer/reader/components/ReaderMenu.tsx | 2 +- src/resources/locales/en.json | 40 +++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/renderer/common/components/ImportAnnotationsDialog.tsx b/src/renderer/common/components/ImportAnnotationsDialog.tsx index 7ba6d953e..45b74cd8f 100644 --- a/src/renderer/common/components/ImportAnnotationsDialog.tsx +++ b/src/renderer/common/components/ImportAnnotationsDialog.tsx @@ -69,10 +69,10 @@ export const ImportAnnotationsDialog: React.FC{__("dialog.annotations.title")} {originTitle ? __("dialog.annotations.origin", { title: originTitle, author: originCreator ? __("dialog.annotations.descAuthor", { author: originCreator }) : "" }) : ""} - {title ? `${__("dialog.annotations.descTitle")}${title}` : ""} + {title ? `${__("dialog.annotations.descTitle")}'${title}'` : ""} {annotationsList.length ? __("dialog.annotations.descList", { nb: annotationsList.length, - creator: creatorNameList.length ? `${__("dialog.annotations.descCreator")} ${creatorNameList.join(", ")}` : "", // TODO i18n + 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 3fb23ceb5..6cafe6daf 100644 --- a/src/renderer/reader/components/ReaderMenu.tsx +++ b/src/renderer/reader/components/ReaderMenu.tsx @@ -1135,7 +1135,7 @@ const AnnotationList: React.FC<{ annotationUUIDFocused: string, resetAnnotationU } return anno; }); - const title = annotationTitleRef?.current.value || "myAnnotationsSet"; + const title = annotationTitleRef?.current.value || "thorium-reader"; let label = title; label = label.trim(); label = label.replace(/[^a-z0-9_-]/gi, "_"); diff --git a/src/resources/locales/en.json b/src/resources/locales/en.json index cab860c47..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,20 +120,20 @@ }, "dialog": { "annotations": { - "descAuthor": "by {{- author}}", + "descAuthor": "by '{{- author}}'", "descCreator": "created by", - "descList": "{{- nb}} note(s) {{- creator}} will be imported to {{- title}} {{- author}}", - "descNewer": "{{- nb}} conflict(s): some notes associated with the publication are newer.", - "descOlder": "{{- nb}} conflict(s): some notes associated with the publication are older.", - "descTitle": "Annotation Set: ", + "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 conflict-free notes only", - "origin": "Origin: {{- title}} {{- author}}", - "title": "Do you want to import all of 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:", @@ -180,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}}]" @@ -327,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", @@ -341,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" @@ -580,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.",