Skip to content

fix(notes): export notes from library window #2934

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 11 additions & 109 deletions src/common/readium/annotation/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@

import * as debug_ from "debug";

import { ICssSelector, IProgressionSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCssSelector, ISelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
import { ICssSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCssSelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
import { v4 as uuidv4 } from "uuid";
import { _APP_NAME, _APP_VERSION } from "readium-desktop/preprocessor-directives";
import { PublicationView } from "readium-desktop/common/views/publication";
import { rgbToHex } from "readium-desktop/common/rgb";
import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache";
import { getDocumentFromICacheDocument } from "readium-desktop/utils/xmlDom";
import { createCssSelectorMatcher, createTextPositionSelectorMatcher, createTextQuoteSelectorMatcher, describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom";
import { createCssSelectorMatcher, createTextPositionSelectorMatcher, createTextQuoteSelectorMatcher } from "readium-desktop/third_party/apache-annotator/dom";
import { makeRefinable } from "readium-desktop/third_party/apache-annotator/selector";
import { convertRange, convertRangeInfo, normalizeRange } from "@r2-navigator-js/electron/renderer/webview/selection";
import { convertRange, normalizeRange } from "@r2-navigator-js/electron/renderer/webview/selection";
import { MiniLocatorExtended } from "readium-desktop/common/redux/states/locatorInitialState";
import { uniqueCssSelector } from "@r2-navigator-js/electron/renderer/common/cssselector3";
import { IRangeInfo, ISelectedTextInfo, ISelectionInfo } from "@r2-navigator-js/electron/common/selection";
Expand Down Expand Up @@ -235,117 +235,19 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn
return locatorExtended;
}

export type INoteStateWithICacheDocument = INoteState & { __cacheDocument?: ICacheDocument | undefined };
// export type INoteStateWithICacheDocument = INoteState & { __cacheDocument?: ICacheDocument | undefined };

const describeCssSelectorWithTextPosition = async (range: Range, document: Document, root: HTMLElement): Promise<ICssSelector<ITextPositionSelector> | undefined> => {
// normalizeRange can fundamentally alter the DOM Range by repositioning / snapping to Text boundaries, this is an internal implementation detail inside navigator when CREATING ranges from user document selections.
// const rangeNormalize = normalizeRange(range); // from r2-nav and not from third-party/apache-annotator
export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IReadiumAnnotation {

const commonAncestorHTMLElement =
(range.commonAncestorContainer && range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE)
? range.commonAncestorContainer as Element
: (range.startContainer.parentNode && range.startContainer.parentNode.nodeType === Node.ELEMENT_NODE)
? range.startContainer.parentNode as Element
: undefined;
if (!commonAncestorHTMLElement) {
return undefined;
}

return {
type: "CssSelector",
value: uniqueCssSelector(commonAncestorHTMLElement, document, { root }),
refinedBy: await describeTextPosition(
range,
commonAncestorHTMLElement,
),
};
};

export async function convertAnnotationStateToSelector(annotationWithCacheDoc: INoteStateWithICacheDocument, isLcp: boolean): Promise<[ISelector[], isABookmark: boolean]> {

const selector: ISelector<any>[] = [];

const {__cacheDocument, ...annotation} = annotationWithCacheDoc;

const xmlDom = getDocumentFromICacheDocument(__cacheDocument);
if (!xmlDom) {
return [[], false];
}

const document = xmlDom;
const root = xmlDom.body;

const { locatorExtended, drawType } = annotation;
const { selectionInfo, locator } = locatorExtended;
const { locations } = locator;
const { progression } = locations;

// the range start/end is guaranteed in document order (internally used in navigator whenever deserialising DOM Ranges from JSON expression) ... but DOM Ranges are always ordered anyway (only the user / document selection object can be reversed)
const rangeInfo = selectionInfo?.rangeInfo || locator.locations.caretInfo?.rangeInfo;
if (!rangeInfo) {
debug("ERROR!! RangeInfo not defined !!!");
debug(rangeInfo);
return [selector, false];
}
const range = convertRangeInfo(xmlDom, rangeInfo);
debug("Dump range memory found:", range);

if (range.collapsed) {
debug("RANGE COLLAPSED??! skipping...");
return [selector, false];
}

// createTextPositionSelectorMatcher()
const selectorCssSelectorWithTextPosition = await describeCssSelectorWithTextPosition(range, document, root);
if (selectorCssSelectorWithTextPosition) {

debug("CssWithTextPositionSelector : ", selectorCssSelectorWithTextPosition);
selector.push(selectorCssSelectorWithTextPosition);
}

// describeTextPosition()
const selectorTextPosition = await describeTextPosition(range, root);
debug("TextPositionSelector : ", selectorTextPosition);
selector.push(selectorTextPosition);

if (!isLcp) {

// describeTextQuote()
const selectorTextQuote = await describeTextQuote(range, root);
debug("TextQuoteSelector : ", selectorTextQuote);
selector.push(selectorTextQuote);
}

const progressionSelector: IProgressionSelector = {
type: "ProgressionSelector",
value: progression || -1,
};
debug("ProgressionSelector : ", progressionSelector);
selector.push(progressionSelector);

// Next TODO: CFI !?!

// this normally occurs at import time, but let's save debugging effort by checking immediately when exporting...
// errors are non-fatal, just hunt for the "IRangeInfo DIFF" console logs
const isABookmark = drawType === EDrawType.bookmark; // rangeInfo.endContainerChildTextNodeIndex === rangeInfo.startContainerChildTextNodeIndex && rangeInfo.endContainerElementCssSelector === rangeInfo.startContainerElementCssSelector && rangeInfo.endOffset - rangeInfo.startOffset === 1;
if (IS_DEV) {
await convertSelectorTargetToLocatorExtended({ source: "", selector }, __cacheDocument, rangeInfo, isABookmark);
}
return [selector, isABookmark];
}

export async function convertAnnotationStateToReadiumAnnotation(annotation: INoteStateWithICacheDocument, isLcp: boolean): Promise<IReadiumAnnotation> {

const { uuid, color, locatorExtended: def, tags, drawType, textualValue, creator, created, modified } = annotation;
const { uuid, color, locatorExtended: def, tags, drawType, textualValue, creator, created, modified, readiumAnnotation } = note;
const { locator, headings, epubPage/*, selectionInfo*/ } = def;
const { href /*text, locations*/ } = locator;
// const { afterRaw, beforeRaw, highlightRaw } = text || {};
// const { rangeInfo: rangeInfoSelection } = selectionInfo || {};
// const { progression } = locations;

const highlight = (drawType === EDrawType.solid_background ? "solid" : EDrawType[drawType]) as IReadiumAnnotation["body"]["highlight"];

const [selector, isABookmark] = await convertAnnotationStateToSelector(annotation, isLcp);
const isABookmark = drawType === EDrawType.bookmark;

return {
"@context": "http://www.w3.org/ns/anno.jsonld",
Expand Down Expand Up @@ -374,17 +276,17 @@ export async function convertAnnotationStateToReadiumAnnotation(annotation: INot
headings: (headings || []).map(({ txt, level }) => ({ txt, level })),
page: epubPage || "",
},
selector,
selector: readiumAnnotation?.export?.selector || [],
},
motivation: isABookmark ? "bookmarking" : undefined, // isABookmark = drawType === EDrawType.bookmark
};
}

export async function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof typeof availableLanguages, annotationArray: INoteStateWithICacheDocument[], publicationView: PublicationView, label?: string): Promise<IReadiumAnnotationSet> {
export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof typeof availableLanguages, notes: INoteState[], publicationView: PublicationView, label?: string): IReadiumAnnotationSet {

const currentDate = new Date();
const dateString: string = currentDate.toISOString();
const isLcp = !!publicationView.lcp;
// const iLcp = !!publicationView.lcp;

return {
"@context": "http://www.w3.org/ns/anno.jsonld",
Expand Down Expand Up @@ -412,6 +314,6 @@ export async function convertAnnotationStateArrayToReadiumAnnotationSet(locale:
}) : [],
"dc:date": publicationView.publishedAt || "",
},
items: await Promise.all((annotationArray || []).map(async (v) => await convertAnnotationStateToReadiumAnnotation(v, isLcp))),
items: notes.map((v) => convertAnnotationStateToReadiumAnnotation(v)),
};
}
9 changes: 8 additions & 1 deletion src/common/redux/states/renderer/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { hexToRgb } from "readium-desktop/common/rgb";
import { TTranslatorKeyParameter } from "readium-desktop/typings/en.translation-keys";
import { MiniLocatorExtended } from "../locatorInitialState";
import { INoteCreator } from "../creator";
import { IReadiumAnnotation } from "readium-desktop/common/readium/annotation/annotationModel.type";
import { IReadiumAnnotation, ISelector } from "readium-desktop/common/readium/annotation/annotationModel.type";

// DO NOT REMOVE THIS COMMENT BLOCK (USED FOR TRANSLATOR KEYS DETECTION DURING CODE SCANNING)
// __("reader.notes.colors.red")
Expand Down Expand Up @@ -92,6 +92,13 @@ export interface INoteState {
created: number;
creator?: INoteCreator;
group: "bookmark" | "annotation";
readiumAnnotation?: {
export?: {
selector: ISelector[];
}

// TODO: import !?
}
}

export type TDrawView = "annotation" | "margin" | "hide";
Expand Down
49 changes: 27 additions & 22 deletions src/main/redux/sagas/annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ const filename_ = "readium-desktop:main:saga:annotationsImporter";
const debug = debug_(filename_);
debug("_");

export function* getAnnotationFromMainWinState(publicationIdentifier: string): SagaGenerator<INoteState[]> {

let annotations: INoteState[] = [];
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?.note || [];

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?.note || [];

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!");
}

return annotations;
}

function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAction): SagaGenerator<void> {

const { payload: { publicationIdentifier, winId } } = action;
Expand Down Expand Up @@ -121,28 +147,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
debug("GOOD ! spineItemHref matched : publication identified, let's continue the importation");

// OK publication identified

let annotations: INoteState[] = [];
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?.note || [];

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?.note || [];

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!");
}

const annotations = yield* callTyped(getAnnotationFromMainWinState, publicationIdentifier);

const annotationsParsedNoConflictArray: INotePreParsingState[] = [];
const annotationsParsedConflictOlderArray: INotePreParsingState[] = [];
Expand Down
28 changes: 27 additions & 1 deletion src/main/streamer/streamerNoHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ import {
// import { OPDS_MEDIA_SCHEME } from "readium-desktop/main/redux/sagas/getEventChannel";
import { THORIUM_READIUM2_ELECTRON_HTTP_PROTOCOL } from "readium-desktop/common/streamerProtocol";
import { findMimeTypeWithExtension } from "readium-desktop/utils/mimeTypes";
import { diMainGet } from "../di";
import { getAnnotationFromMainWinState } from "../redux/sagas/annotation";
import { INoteState } from "readium-desktop/common/redux/states/renderer/note";

// import { _USE_HTTP_STREAMER } from "readium-desktop/preprocessor-directives";

Expand Down Expand Up @@ -215,6 +218,9 @@ const streamProtocolHandler = async (
}
}

const notesFromPublicationPrefix = "/publication-notes/";
const isNotesFromPublicationRequest = uPathname.startsWith(notesFromPublicationPrefix);

const pdfjsAssetsPrefix = "/pdfjs/";
const isPdfjsAssets = uPathname.startsWith(pdfjsAssetsPrefix);

Expand Down Expand Up @@ -274,7 +280,27 @@ const streamProtocolHandler = async (
headers["Access-Control-Allow-Headers"] = "Content-Type, Content-Length, Accept-Ranges, Content-Range, Range, Link, Transfer-Encoding, X-Requested-With, Authorization, Accept, Origin, User-Agent, DNT, Cache-Control, Keep-Alive, If-Modified-Since";
headers["Access-Control-Expose-Headers"] = "Content-Type, Content-Length, Accept-Ranges, Content-Range, Range, Link, Transfer-Encoding, X-Requested-With, Authorization, Accept, Origin, User-Agent, DNT, Cache-Control, Keep-Alive, If-Modified-Since";

if (isPdfjsAssets) {
if (isNotesFromPublicationRequest) {

const publicationUUID = uPathname.substr(notesFromPublicationPrefix.length);

const sagaMiddleware = diMainGet("saga-middleware");
const notes = await sagaMiddleware.run(getAnnotationFromMainWinState, publicationUUID).toPromise<INoteState[]>();
const notesSerialized = JSON.stringify(notes);
const notesSerializedBuf = Buffer.from(notesSerialized, "utf-8");
const contentLength = `${notesSerializedBuf.length || 0}`;
headers["Content-Length"] = contentLength;
const contentType = "application/json; charset=utf-8";
headers["Content-Type"] = contentType;

const obj = {
data: bufferToStream(notesSerializedBuf),
headers,
statusCode: 200,
};
callback(obj);
return;
} else if (isPdfjsAssets) {

const pdfjsUrlPathname = uPathname.substr(pdfjsAssetsPrefix.length);
debug("PDFJS request this file:", pdfjsUrlPathname);
Expand Down
Loading