Skip to content
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
4 changes: 3 additions & 1 deletion src/common/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import { ObjectValues } from "readium-desktop/utils/object-keys-values";

export const EXT_THORIUM = ".thorium";
export const EXT_ANNOTATIONS = ".annotation";
export const EXT_ANNOTATIONS = ".annotations";
export const EXT_ANNOTATIONS_LEGACY = ".annotation";
export const ANNOTATIONS_JSON_FILENAME = "annotations.json";

// cf src/utils/mimeTypes.ts
export const acceptedExtensionObject = {
Expand Down
31 changes: 26 additions & 5 deletions src/common/readium/annotation/annotationModel.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
import Ajv from "ajv";
import addFormats from "ajv-formats";

export const EPUB_ANNOTATION_CONTEXT = "https://www.w3.org/ns/epub-anno.jsonld" as const;
export const LEGACY_ANNOTATION_CONTEXT = "http://www.w3.org/ns/anno.jsonld" as const;

export type TAnnotationContext = typeof EPUB_ANNOTATION_CONTEXT | typeof LEGACY_ANNOTATION_CONTEXT;

export interface IReadiumAnnotationSet {
"@context": "http://www.w3.org/ns/anno.jsonld";
"@context": TAnnotationContext;
id: string;
type: "AnnotationSet";
generator?: Generator;
Expand All @@ -20,7 +25,7 @@ export interface IReadiumAnnotationSet {
}

export interface IReadiumAnnotation {
"@context": "http://www.w3.org/ns/anno.jsonld";
"@context"?: TAnnotationContext;
id: string;
created: string;
modified?: string;
Expand All @@ -34,6 +39,7 @@ export interface IReadiumAnnotation {
type: string;
value: string;
tag?: string;
tags?: string[];
highlight?: "solid" | "underline" | "strikethrough" | "outline" | "bookmark";
color?: string;
textDirection?: "ltr" | "rtl";
Expand Down Expand Up @@ -198,7 +204,7 @@ export const readiumAnnotationSetSchema = {
"properties": {
"@context": {
"type": "string",
"const": "http://www.w3.org/ns/anno.jsonld",
"enum": [EPUB_ANNOTATION_CONTEXT, LEGACY_ANNOTATION_CONTEXT],
},
"id": {
"type": "string",
Expand Down Expand Up @@ -290,7 +296,7 @@ export const readiumAnnotationSetSchema = {
"properties": {
"@context": {
"type": "string",
"const": "http://www.w3.org/ns/anno.jsonld",
"enum": [EPUB_ANNOTATION_CONTEXT, LEGACY_ANNOTATION_CONTEXT],
},
"id": {
"type": "string",
Expand Down Expand Up @@ -343,6 +349,13 @@ export const readiumAnnotationSetSchema = {
"type": "string",
"nullable": true,
},
"tags": {
"type": "array",
"items": {
"type": "string",
},
"nullable": true,
},
"highlight": {
"type": "string",
"enum": ["solid", "underline", "strikethrough", "outline", "bookmark"],
Expand Down Expand Up @@ -418,7 +431,7 @@ export const readiumAnnotationSetSchema = {
"required": ["source", "selector"],
},
},
"required": ["@context", "id", "created", "type", "target"],
"required": ["id", "created", "type", "target"],
},
"Selector": {
"type": "object",
Expand Down Expand Up @@ -494,3 +507,11 @@ export function isIReadiumAnnotationSet(data: any): data is IReadiumAnnotationSe
return valid;
}

export function normalizeReadiumAnnotationTags(body: IReadiumAnnotation["body"] | undefined): string[] {
const tags = body?.tags || [];
const legacyTag = body?.tag;

return [...tags, ...(legacyTag ? [legacyTag] : [])]
.filter((tag, index, allTags) => allTags.indexOf(tag) === index);
}

25 changes: 17 additions & 8 deletions src/common/readium/annotation/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import debug_ from "debug";

import { ICssSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isLegacyCfiSelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
import { LEGACY_ANNOTATION_CONTEXT, TAnnotationContext, EPUB_ANNOTATION_CONTEXT, ICssSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isLegacyCfiSelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
import { uuidv4 } from "readium-desktop/utils/uuid";
import { _APP_NAME, _APP_VERSION } from "readium-desktop/preprocessor-directives";
import { PublicationView } from "readium-desktop/common/views/publication";
Expand Down Expand Up @@ -308,11 +308,20 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn

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

export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IReadiumAnnotation | undefined {
export function readiumAnnotationDrawType(annotation: IReadiumAnnotation): EDrawType {
if (annotation.motivation === "bookmarking") {
return EDrawType.bookmark;
}
const highlight = annotation.body?.highlight;
return highlight && highlight !== "solid" ? EDrawType[highlight] : EDrawType.solid_background;
}

export function convertAnnotationStateToReadiumAnnotation(note: INoteState, context: TAnnotationContext = EPUB_ANNOTATION_CONTEXT): IReadiumAnnotation | undefined {

const { uuid, color, locatorExtended, tags, drawType, textualValue, creator, created, modified, readiumAnnotation } = note;
const highlight = (drawType === EDrawType.solid_background ? "solid" : EDrawType[drawType]) as IReadiumAnnotation["body"]["highlight"];
const isABookmark = drawType === EDrawType.bookmark;
const highlight = isABookmark && context === EPUB_ANNOTATION_CONTEXT ? undefined :
(drawType === EDrawType.solid_background ? "solid" : EDrawType[drawType]) as IReadiumAnnotation["body"]["highlight"];

// PDF annotations currently store their target in `note.pdfAnnotation`.
// Do not serialize them as Readium annotations until there is an explicit
Expand All @@ -334,7 +343,7 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
} : undefined;

return {
"@context": "http://www.w3.org/ns/anno.jsonld",
"@context": context,
id: uuid ? "urn:uuid:" + uuid : "",
created: new Date(created).toISOString(),
modified: modified ? new Date(modified).toISOString() : undefined,
Expand All @@ -344,7 +353,7 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
value: textualValue || "",
format: "text/plain",
color: noteColorCodeToColorSet[rgbToHex(color)] || NOTE_DEFAULT_COLOR,
tag: (tags || [])[0] || "",
...(context === LEGACY_ANNOTATION_CONTEXT ? { tag: tags?.[0] || "" } : tags?.length ? { tags: [...tags] } : {}),
highlight,
// textDirection: "ltr",
// language: "fr",
Expand All @@ -364,14 +373,14 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
};
}

export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof typeof availableLanguages, notes: INoteState[], publicationView: PublicationView, label?: string): IReadiumAnnotationSet {
export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof typeof availableLanguages, notes: INoteState[], publicationView: PublicationView, label?: string, context: TAnnotationContext = publicationView.isEPUB ? EPUB_ANNOTATION_CONTEXT : LEGACY_ANNOTATION_CONTEXT): IReadiumAnnotationSet {

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

return {
"@context": "http://www.w3.org/ns/anno.jsonld",
"@context": context,
id: "urn:uuid:" + uuidv4(),
type: "AnnotationSet",
generator: {
Expand Down Expand Up @@ -409,7 +418,7 @@ export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof
"dc:date": publicationView.publishedAt || "",
},
items: notes.reduce<IReadiumAnnotation[]>((items, note) => {
const readiumAnnotation = convertAnnotationStateToReadiumAnnotation(note);
const readiumAnnotation = convertAnnotationStateToReadiumAnnotation(note, context);
if (readiumAnnotation) {
items.push(readiumAnnotation);
}
Expand Down
11 changes: 10 additions & 1 deletion src/common/readium/annotation/htmlTemplate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import type { IReadiumAnnotation } from "./annotationModel.type";

export function annotationHtmlBody(annotation: IReadiumAnnotation): IReadiumAnnotation["body"] {
return {
...annotation.body,
tag: annotation.body?.tags?.[0] || annotation.body?.tag || "",
tags: annotation.body?.tags || (annotation.body?.tag ? [annotation.body.tag] : []),
};
}

export const noteExportHtmlMustacheTemplate = `
<!DOCTYPE html>
Expand Down Expand Up @@ -79,7 +88,7 @@ export const noteExportHtmlMustacheTemplate = `
{{/body.value}}
<!-- Annotation Metadata -->
<p class="annotationmetadata">
{{#body.tag}} | Tag: {{body.tag}}{{/body.tag}}
{{#body.tags}} | Tag: {{.}}{{/body.tags}}
{{#body.color}} | Color: {{body.color}}{{/body.color}}
{{#body.highlight}} | Highlight type: {{body.highlight}}{{/body.highlight}};
{{#body.textDirection}}TextDirection: | {{body.textDirection}}{{/body.textDirection}}
Expand Down
1 change: 1 addition & 0 deletions src/common/views/publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface PublicationView extends Identifiable {

// Post-check that the publication storage is valid for opening
isOpenable: boolean;
isEPUB?: boolean;
isAudio?: boolean;
isDivina?: boolean;
isPDF?: boolean;
Expand Down
2 changes: 2 additions & 0 deletions src/main/converter/publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ export class PublicationViewConverter {
isDivina,
isPDF,
isDaisy,
isEPUB: !isAudio && !isDivina && !isPDF && !isDaisy &&
!!document.files?.some((file) => file.ext.toLowerCase() === "epub"),
isFixedLayoutPublication,
lastReadTimeStamp,
readingFinished,
Expand Down
31 changes: 17 additions & 14 deletions src/main/redux/sagas/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import debug_ from "debug";
import { dialog } from "electron";
import * as fs from "node:fs";
import { buildPublicationUserAnalyticsParams, publicationAnalyticsEvents } from "readium-desktop/common/analytics/publication";
import { ToastType } from "readium-desktop/common/models/toast";
import { annotationActions, readerActions, toastActions } from "readium-desktop/common/redux/actions";
Expand All @@ -16,8 +15,8 @@ import { error } from "readium-desktop/main/tools/error";
import { SagaGenerator } from "typed-redux-saga";
import { call as callTyped, put as putTyped, take as takeTyped, delay as delayTyped, all as allTyped } from "typed-redux-saga/macro";
import { hexToRgb } from "readium-desktop/common/rgb";
import { isNil } from "readium-desktop/utils/nil";
import { __READIUM_ANNOTATION_AJV_ERRORS, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isFragmentSelector, isIReadiumAnnotationSet, isLegacyCfiSelector, isTextPositionSelector, isTextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type";
import { readiumAnnotationDrawType } from "readium-desktop/common/readium/annotation/converter";
import { __READIUM_ANNOTATION_AJV_ERRORS, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isFragmentSelector, isIReadiumAnnotationSet, isLegacyCfiSelector, isTextPositionSelector, isTextQuoteSelector, normalizeReadiumAnnotationTags } from "readium-desktop/common/readium/annotation/annotationModel.type";
import path from "node:path";
import { getPublication } from "./api/publication/getPublication";
import { Publication as R2Publication } from "@r2-shared-js/models/publication";
Expand All @@ -26,14 +25,15 @@ import { tryCatchSync } from "readium-desktop/utils/tryCatch";
import { uuidv4 } from "readium-desktop/utils/uuid";
import { takeSpawnLatest } from "readium-desktop/common/redux/sagas/takeSpawnLatest";
import { getTranslator } from "readium-desktop/common/services/translator";
import { EDrawType, INoteState, NOTE_DEFAULT_COLOR, noteColorCodeToColorSet, noteColorSetToColorCode } from "readium-desktop/common/redux/states/renderer/note";
import { INoteState, NOTE_DEFAULT_COLOR, noteColorCodeToColorSet, noteColorSetToColorCode } from "readium-desktop/common/redux/states/renderer/note";
import { takeSpawnLeading } from "readium-desktop/common/redux/sagas/takeSpawnLeading";
import { sqliteTableNoteDelete, sqliteTableNoteDeleteWherePubId, sqliteTableNoteInsert, sqliteTableNoteUpdate, sqliteTableSelectAllNotesWherePubId } from "readium-desktop/main/db/sqlite/note";
import { publicationActions as publicationActionsFromMainAction } from "../actions";
import { EXT_ANNOTATIONS } from "readium-desktop/common/extension";
import { EXT_ANNOTATIONS, EXT_ANNOTATIONS_LEGACY } from "readium-desktop/common/extension";
import { resolveReadiumAnnotationSourceHref } from "readium-desktop/common/readium/annotation/sourceHref";
import { spawnPublicationAnalyticsEvent } from "./analyticsPublication";
import { TAnalyticsEventParams } from "src/common/api/interface/analyticsApi.interface";
import { readAnnotationSetFile } from "readium-desktop/main/w3c/annotations/read";

// Logger
const filename_ = "readium-desktop:main:saga:annotationsImporter";
Expand Down Expand Up @@ -116,11 +116,14 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
try {

debug("Open ShowOpenDialog and ask to user the filePath");
const res = yield* callTyped(() => dialog.showOpenDialog(win, { filters: [{ extensions: [EXT_ANNOTATIONS.substring(1)], name: __("reader.marks.annotationsReadium") + " [" + EXT_ANNOTATIONS + "]" }], properties: ["openFile"] }));

if (!res.canceled) {
filePath = res.filePaths[0] || "";
const res = yield* callTyped(() => dialog.showOpenDialog(win, { filters: [{ extensions: [EXT_ANNOTATIONS.substring(1), EXT_ANNOTATIONS_LEGACY.substring(1)], name: __("reader.marks.annotationsReadium") + ` [${EXT_ANNOTATIONS}, ${EXT_ANNOTATIONS_LEGACY}]` }], properties: ["openFile"] }));

if (res.canceled) {
return;
}
filePath = res.filePaths[0] || "";
if (!filePath) {
return;
}
} catch (e) {
debug("Error!!! to open a file, exit", e);
Expand All @@ -129,12 +132,12 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
}

debug("FilePath=", filePath);
const fileName = path.basename(filePath).slice(0, -1 * EXT_ANNOTATIONS.length);
const fileName = path.basename(filePath, path.extname(filePath));

try {

// read filePath
const dataString = yield* callTyped(() => fs.promises.readFile(filePath, { encoding: "utf8" }));
const dataString = yield* callTyped(() => readAnnotationSetFile(filePath));
const readiumAnnotationFormat = JSON.parse(dataString);
debug("filePath size=", dataString.length);
debug("filePath serialized and ready to pass the type checker");
Expand Down Expand Up @@ -216,6 +219,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
// loop on each annotation to check conflicts and import it
for (const incommingAnnotation of annotationsIncommingArray) {
const creator = incommingAnnotation.creator;
const importedTags = normalizeReadiumAnnotationTags(incommingAnnotation.body);

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

Expand Down Expand Up @@ -257,9 +261,8 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
color: hexToRgb(noteColorSetToColorCode[incommingAnnotation.body?.color] ||
noteColorSetToColorCode[noteColorCodeToColorSet[incommingAnnotation.body?.color] || NOTE_DEFAULT_COLOR],
),
drawType: EDrawType[(isNil(incommingAnnotation.body?.highlight) || incommingAnnotation.body?.highlight === "solid") ? "solid_background" : incommingAnnotation.body.highlight] || EDrawType.solid_background,
// 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] : [],
drawType: readiumAnnotationDrawType(incommingAnnotation),
tags: importedTags.length ? importedTags : [fileName],
modified: incommingAnnotation.modified ? tryCatchSync(() => new Date(incommingAnnotation.modified).getTime(), fileName) : undefined,
created: tryCatchSync(() => new Date(incommingAnnotation.created).getTime(), fileName) || currentTimestamp,
creator: creator?.id ? {
Expand Down
41 changes: 41 additions & 0 deletions src/main/w3c/annotations/read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// ==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 fs from "node:fs";
import path from "node:path";
import StreamZip from "node-stream-zip";

import { ANNOTATIONS_JSON_FILENAME, EXT_ANNOTATIONS, EXT_ANNOTATIONS_LEGACY } from "readium-desktop/common/extension";

async function readDetachedAnnotationPackage(filePath: string): Promise<string> {
const zip = new StreamZip.async({ file: filePath });

try {
const entry = await zip.entry(ANNOTATIONS_JSON_FILENAME);
if (!entry || entry.isDirectory) {
throw new Error(`The annotation package does not contain a root-level ${ANNOTATIONS_JSON_FILENAME} file.`);
}

const data = await zip.entryData(entry);
return data.toString("utf8");
} finally {
await zip.close().catch((): void => undefined);
}
}

export async function readAnnotationSetFile(filePath: string): Promise<string> {
const extension = path.extname(filePath).toLowerCase();

if (extension === EXT_ANNOTATIONS_LEGACY) {
return fs.promises.readFile(filePath, { encoding: "utf8" });
}
if (extension === EXT_ANNOTATIONS) {
return readDetachedAnnotationPackage(filePath);
}

throw new Error(`Unsupported annotation file extension: ${extension}`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +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==

import { ZipFile } from "yazl";

import { ANNOTATIONS_JSON_FILENAME } from "readium-desktop/common/extension";

export function createDetachedAnnotationPackage(serializedAnnotationSet: string): Promise<ArrayBuffer> {
return new Promise<ArrayBuffer>((resolve, reject) => {
const zipFile = new ZipFile();
const chunks: Buffer[] = [];

zipFile.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk));
zipFile.outputStream.once("error", reject);
zipFile.outputStream.once("end", () => {
const archive = Buffer.concat(chunks);
resolve(archive.buffer.slice(
archive.byteOffset,
archive.byteOffset + archive.byteLength,
) as ArrayBuffer);
});

zipFile.addBuffer(Buffer.from(serializedAnnotationSet, "utf8"), ANNOTATIONS_JSON_FILENAME);
zipFile.end();
});
}
Loading
Loading