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 !?! */}
+ {/* !!!! */}
+ {/* !!!! */}
+ {/* !!!! */}
+ {/* !!!! */}
+ {/* !!!! */}