From 403a942b55bc65cdb4f5b23c0d89d8dfa82f11b9 Mon Sep 17 00:00:00 2001 From: Pierre Leroux Date: Wed, 11 Dec 2024 10:11:21 +0100 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] [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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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",