diff --git a/src/common/models/dialog.ts b/src/common/models/dialog.ts index c51e2ea409..c4c4cd4455 100644 --- a/src/common/models/dialog.ts +++ b/src/common/models/dialog.ts @@ -6,17 +6,17 @@ // ==LICENSE-END== import { TPublication } from "readium-desktop/common/type/publication.type"; -import { IOpdsFeedView } from "readium-desktop/common/views/opds"; +import { IOpdsFeedView, IOpdsPublicationView } from "readium-desktop/common/views/opds"; import { PublicationView } from "readium-desktop/common/views/publication"; import { MiniLocatorExtended } from "readium-desktop/common/redux/states/locatorInitialState"; import { IReaderDialogOrDockSettingsMenuState } from "./reader"; -interface IPubInfoState { - publication?: TPublication; +interface IPubInfoState { + publication?: T; coverZoom?: boolean; } -interface IPubInfoStateReader extends IPubInfoState { +interface IPubInfoStateReader extends IPubInfoState { focusWhereAmI: boolean; pdfPlayerNumberOfPages: number | undefined; // super hacky :( divinaNumberOfPages: number | undefined; // super hacky :( @@ -54,8 +54,8 @@ export interface DialogType { [DialogTypeName.FileImport]: { files: IFileImport[]; }; - [DialogTypeName.PublicationInfoOpds]: IPubInfoState; - [DialogTypeName.PublicationInfoLib]: IPubInfoState; + [DialogTypeName.PublicationInfoOpds]: IPubInfoState; + [DialogTypeName.PublicationInfoLib]: IPubInfoState; [DialogTypeName.PublicationInfoReader]: IPubInfoStateReader; [DialogTypeName.OpdsFeedAddForm]: {}; [DialogTypeName.OpdsFeedUpdateForm]: { diff --git a/src/common/redux/actions/dialog/updateRequest.ts b/src/common/redux/actions/dialog/updateRequest.ts index f5abf137e2..093a207283 100644 --- a/src/common/redux/actions/dialog/updateRequest.ts +++ b/src/common/redux/actions/dialog/updateRequest.ts @@ -11,10 +11,10 @@ import { Action } from "readium-desktop/common/models/redux"; export const ID = "DIALOG_UPDATE_REQUEST"; export interface Payload { - data: DialogType[T]; + data: Partial; } -export function build(data: DialogType[T]): +export function build(data: Partial): Action> { return { diff --git a/src/main/converter/publication.ts b/src/main/converter/publication.ts index c751f6e61f..34722ece9e 100644 --- a/src/main/converter/publication.ts +++ b/src/main/converter/publication.ts @@ -27,6 +27,7 @@ import { diMainGet } from "../di"; import { lcpLicenseIsNotWellFormed } from "readium-desktop/common/lcp"; import { LCP } from "@r2-lcp-js/parser/epub/lcp"; import { MiniLocatorExtended } from "readium-desktop/common/redux/states/locatorInitialState"; +import { applyOpdsPublicationViewFallback } from "./tools/publicationViewFallback"; // import { type Store } from "redux"; // import { RootState } from "../redux/states"; @@ -226,7 +227,7 @@ export class PublicationViewConverter { isOpenable, } = await getPublicationStorageState(document.identifier); - return { + return applyOpdsPublicationViewFallback({ isOpenable, identifier: document.identifier, // preserve Identifiable identifier @@ -240,7 +241,7 @@ export class PublicationViewConverter { customCover: document.customCover, r2PublicationJson: undefined, lastReadingLocation: readerStateLocator, - }; + }, document.opdsPublicationView); } @@ -342,7 +343,7 @@ export class PublicationViewConverter { isOpenable, } = await getPublicationStorageState(document.identifier); - return { + return applyOpdsPublicationViewFallback({ isOpenable, isAudio, @@ -407,6 +408,6 @@ export class PublicationViewConverter { // r2PublicationBase64, lastReadingLocation: readerStateLocator, - }; + }, document.opdsPublicationView); } } diff --git a/src/main/converter/tools/publicationViewFallback.ts b/src/main/converter/tools/publicationViewFallback.ts new file mode 100644 index 0000000000..8b15bda248 --- /dev/null +++ b/src/main/converter/tools/publicationViewFallback.ts @@ -0,0 +1,98 @@ +// ==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 type { IOpdsPublicationView } from "readium-desktop/common/views/opds"; +import type { PublicationView } from "readium-desktop/common/views/publication"; + +const metadataIsMissing = (value: unknown): boolean => { + if (value === undefined || value === null) { + return true; + } + if (typeof value === "string") { + return !value.trim(); + } + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof value === "object") { + return Object.values(value).every(metadataIsMissing); + } + return false; +}; + +const metadataFallback = (value: T | undefined, fallback: T | undefined): T | undefined => + metadataIsMissing(value) ? fallback : value; + +const titleWithoutPlaceholder = (title: T | undefined): T | undefined => + title === "-" ? undefined : title; + +export const applyOpdsPublicationViewFallback = ( + publicationView: PublicationView, + opdsPublicationView?: IOpdsPublicationView, +): PublicationView => { + if (!opdsPublicationView) { + return publicationView; + } + + const documentTitle = metadataFallback( + titleWithoutPlaceholder(publicationView.documentTitle), + opdsPublicationView.documentTitle, + ) || publicationView.documentTitle; + const publicationTitle = metadataFallback( + titleWithoutPlaceholder(publicationView.publicationTitle), + opdsPublicationView.documentTitle, + ) || documentTitle; + const authorsLangString = opdsPublicationView.authorsLangString?.map( + (contributor) => contributor.nameLangString, + ); + const publishersLangString = opdsPublicationView.publishersLangString?.map( + (contributor) => contributor.nameLangString, + ); + const tags = opdsPublicationView.tags?.map((tag) => tag.name); + + return { + ...publicationView, + documentTitle, + publicationTitle, + authorsLangString: metadataFallback(publicationView.authorsLangString, authorsLangString) || [], + publishersLangString: metadataFallback(publicationView.publishersLangString, publishersLangString), + description: metadataFallback(publicationView.description, opdsPublicationView.description), + languages: metadataFallback(publicationView.languages, opdsPublicationView.languages), + workIdentifier: metadataFallback(publicationView.workIdentifier, opdsPublicationView.workIdentifier), + publishedAt: metadataFallback(publicationView.publishedAt, opdsPublicationView.publishedAt), + tags: metadataFallback(publicationView.tags, tags), + duration: metadataFallback(publicationView.duration, opdsPublicationView.duration), + nbOfTracks: metadataFallback(publicationView.nbOfTracks, opdsPublicationView.nbOfTracks), + a11y_accessMode: metadataFallback(publicationView.a11y_accessMode, opdsPublicationView.a11y_accessMode), + a11y_accessibilityFeature: metadataFallback( + publicationView.a11y_accessibilityFeature, + opdsPublicationView.a11y_accessibilityFeature, + ), + a11y_accessibilityHazard: metadataFallback( + publicationView.a11y_accessibilityHazard, + opdsPublicationView.a11y_accessibilityHazard, + ), + a11y_certifiedBy: metadataFallback(publicationView.a11y_certifiedBy, opdsPublicationView.a11y_certifiedBy), + a11y_certifierCredential: metadataFallback( + publicationView.a11y_certifierCredential, + opdsPublicationView.a11y_certifierCredential, + ), + a11y_certifierReport: metadataFallback( + publicationView.a11y_certifierReport, + opdsPublicationView.a11y_certifierReport, + ), + a11y_conformsTo: metadataFallback(publicationView.a11y_conformsTo, opdsPublicationView.a11y_conformsTo), + a11y_accessModeSufficient: metadataFallback( + publicationView.a11y_accessModeSufficient, + opdsPublicationView.a11y_accessModeSufficient, + ), + a11y_accessibilitySummary: metadataFallback( + publicationView.a11y_accessibilitySummary, + opdsPublicationView.a11y_accessibilitySummary, + ), + }; +}; diff --git a/src/main/db/document/publication.ts b/src/main/db/document/publication.ts index b361aa1c38..be2481efbd 100644 --- a/src/main/db/document/publication.ts +++ b/src/main/db/document/publication.ts @@ -10,6 +10,7 @@ import { File } from "readium-desktop/common/models/file"; import { Identifiable } from "readium-desktop/common/models/identifiable"; import { LcpInfo } from "readium-desktop/common/models/lcp"; import { Timestampable } from "readium-desktop/common/models/timestampable"; +import type { IOpdsPublicationView } from "readium-desktop/common/views/opds"; // import { JsonMap } from "readium-desktop/typings/json"; // export interface Resources { @@ -42,6 +43,7 @@ export interface PublicationDocument extends Identifiable, Timestampable { hash: string; opdsPublicationStringified?: string; + opdsPublicationView?: IOpdsPublicationView; opdsPublication?: { url?: string; type?: string; diff --git a/src/main/redux/sagas/api/publication/import/importFromLink.ts b/src/main/redux/sagas/api/publication/import/importFromLink.ts index a583902ff0..206606a61b 100644 --- a/src/main/redux/sagas/api/publication/import/importFromLink.ts +++ b/src/main/redux/sagas/api/publication/import/importFromLink.ts @@ -39,6 +39,7 @@ import { customizationWellKnownFolder } from "readium-desktop/main/customization import * as fs from "node:fs"; import { URL_PATH_PREFIX_CUSTOMPROFILEZIP } from "readium-desktop/common/streamerProtocol"; import { downloadOpdsCoverData, selectOpdsCoverLink } from "./opdsCover"; +import { buildOpdsPublicationDocumentPatch } from "readium-desktop/main/tools/publicationDocument"; // Logger const debug = debug_("readium-desktop:main#saga/api/publication/importFromLinkService"); @@ -114,6 +115,7 @@ function* importLinkFromPath( } let returnPublicationDocument = publicationDocumentWithCover; + const opdsPublicationDocumentPatch = buildOpdsPublicationDocumentPatch(link, pub); if (!alreadyImported && publicationDocumentWithCover) { const tags = pub?.tags?.map((v) => v.name) || []; @@ -137,8 +139,7 @@ function* importLinkFromPath( // // r2OpdsPublicationBase64: pub?.r2OpdsPublicationBase64 || "", // } as Resources, tags, - opdsPublicationStringified: pub?.opdsPublicationStringified, - opdsPublication: { url: link.url, type: link.type, selfLinkUrl: pub?.selfLink?.url, identifier: pub?.workIdentifier }, + ...opdsPublicationDocumentPatch, }, ); @@ -165,8 +166,7 @@ function* importLinkFromPath( // // r2LSDBase64: publicationDocument.resources.r2LSDBase64, // // r2OpdsPublicationBase64: pub?.r2OpdsPublicationBase64 || "", // } as Resources, - opdsPublicationStringified: pub?.opdsPublicationStringified, - opdsPublication: { url: link.url, type: link.type, selfLinkUrl: pub?.selfLink?.url, identifier: pub?.workIdentifier }, + ...opdsPublicationDocumentPatch, }, ); diff --git a/src/main/tools/publicationDocument.ts b/src/main/tools/publicationDocument.ts index 37b00093e6..3274c74f30 100644 --- a/src/main/tools/publicationDocument.ts +++ b/src/main/tools/publicationDocument.ts @@ -7,6 +7,8 @@ import { CustomCover, RandomCustomCovers } from "readium-desktop/common/models/custom-cover"; import { File } from "readium-desktop/common/models/file"; +import type { IOpdsLinkView, IOpdsPublicationView } from "readium-desktop/common/views/opds"; +import type { PublicationDocument } from "readium-desktop/main/db/document/publication"; export interface IPublicationFilesDocumentPatch { coverFile?: File; @@ -14,6 +16,11 @@ export interface IPublicationFilesDocumentPatch { files: File[]; } +export type TOpdsPublicationDocumentPatch = Pick< + PublicationDocument, + "opdsPublication" | "opdsPublicationStringified" | "opdsPublicationView" +>; + export const pickRandomCustomCover = (): CustomCover => RandomCustomCovers[Math.floor(Math.random() * RandomCustomCovers.length)]; @@ -39,3 +46,17 @@ export const buildPublicationFilesDocumentPatch = ( files, }; }; + +export const buildOpdsPublicationDocumentPatch = ( + link: IOpdsLinkView, + publication?: IOpdsPublicationView, +): TOpdsPublicationDocumentPatch => ({ + opdsPublicationStringified: publication?.opdsPublicationStringified, + opdsPublicationView: publication, + opdsPublication: { + url: link.url, + type: link.type, + selfLinkUrl: publication?.selfLink?.url, + identifier: publication?.workIdentifier, + }, +}); diff --git a/src/renderer/common/components/dialog/publicationInfos/publicationInfoContent.tsx b/src/renderer/common/components/dialog/publicationInfos/publicationInfoContent.tsx index 39cfa2a9a7..867b08cdaf 100644 --- a/src/renderer/common/components/dialog/publicationInfos/publicationInfoContent.tsx +++ b/src/renderer/common/components/dialog/publicationInfos/publicationInfoContent.tsx @@ -60,13 +60,13 @@ export interface IProps { pdfPlayerNumberOfPages: number | undefined; // super hacky :( divinaNumberOfPages: number | undefined; // super hacky :( divinaContinousEqualTrue: boolean; - readerReadingLocation: MiniLocatorExtended; + readerReadingLocation: MiniLocatorExtended | undefined; onClickLinkCb?: (tag: IOpdsContributorView) => () => void | undefined; closeDialogCb: () => void; } const Duration = (props: { - duration: number; + duration: number | undefined; __: I18nFunction; }) => { @@ -94,7 +94,7 @@ const Progression = (props: { r2Publication: R2Publication | null, manifestUrlR2Protocol: string | null, handleLinkUrl: ((url: string) => void) | undefined; - locatorExt: MiniLocatorExtended, + locatorExt: MiniLocatorExtended | undefined, focusWhereAmI: boolean, pdfPlayerNumberOfPages: number | undefined, // super hacky :( divinaNumberOfPages: number | undefined, // super hacky :( @@ -117,11 +117,17 @@ const Progression = (props: { // (Audiobooks, PDF, Divina, EPUB FXL and reflow ... page number vs. string types) try { - const isAudio = locatorExt.audioPlaybackInfo + const audioPlaybackInfo = locatorExt.audioPlaybackInfo; + const globalTime = audioPlaybackInfo?.globalTime; + const globalDuration = audioPlaybackInfo?.globalDuration; + const locatorPosition = locatorExt.locator.locations.position; + const isAudio = audioPlaybackInfo // total duration can be undefined with badly-constructed publications, // for example we found some LibriVox W3C LPF audiobooks missing duration property on reading order resources - && locatorExt.audioPlaybackInfo.globalDuration - && typeof locatorExt.locator.locations.position === "number"; // .progression is local to audio item in reading order playlist + && typeof globalTime === "number" + && typeof globalDuration === "number" + && globalDuration > 0 + && typeof locatorPosition === "number"; // .progression is local to audio item in reading order playlist const isDivina = r2Publication && isDivinaFn(r2Publication); const isPdf = r2Publication && isPdfFn(r2Publication); @@ -135,12 +141,13 @@ const Progression = (props: { let txtHeadings: JSX.Element | undefined; if (isAudio) { - const percent = Math.round(locatorExt.locator.locations.position * 100); + const percent = Math.round(locatorPosition * 100); // const p = Math.round(100 * (locatorExt.audioPlaybackInfo.globalTime / locatorExt.audioPlaybackInfo.globalDuration)); - txtProgression = `${percent}% [${formatTime(Math.round(locatorExt.audioPlaybackInfo.globalTime))} / ${formatTime(Math.round(locatorExt.audioPlaybackInfo.globalDuration))}]`; + txtProgression = `${percent}% [${formatTime(Math.round(globalTime))} / ${formatTime(Math.round(globalDuration))}]`; } else if (isDivina) { // console.log("----- ".repeat(100), divinaNumberOfPages, r2Publication?.Spine?.length); - let totalPages = (divinaNumberOfPages && !divinaContinousEqualTrue) ? divinaNumberOfPages : (r2Publication?.Spine?.length ? r2Publication.Spine.length : undefined); + const spineLength = r2Publication?.Spine?.length; + let totalPages = (divinaNumberOfPages && !divinaContinousEqualTrue) ? divinaNumberOfPages : spineLength; if (typeof totalPages === "string") { try { totalPages = parseInt(totalPages, 10); @@ -149,9 +156,11 @@ const Progression = (props: { } } - let pageNum = !divinaContinousEqualTrue ? - (locatorExt.locator.locations.position || 0) : - (Math.floor(locatorExt.locator.locations.progression * r2Publication.Spine.length) - 1); + let pageNum = !divinaContinousEqualTrue + ? (locatorPosition || 0) + : spineLength + ? (Math.floor(locatorExt.locator.locations.progression * spineLength) - 1) + : undefined; if (typeof pageNum === "string") { try { pageNum = parseInt(pageNum, 10) + 1; @@ -243,7 +252,7 @@ const Progression = (props: { const percent = Math.round(locatorExt.locator.locations.progression * 100); txtProgression = `${spineIndex + 1}/${r2Publication.Spine.length}${locatorExt.locator.title ? ` (${locatorExt.locator.title})` : ""} [${percent}%]`; - if (locatorExt.headings && manifestUrlR2Protocol) { // focusWhereAmI + if (locatorExt.headings && manifestUrlR2Protocol && handleLinkUrl) { // focusWhereAmI let rank = 999; const hs = locatorExt.headings.filter((h, _i) => { diff --git a/src/renderer/common/components/dialog/publicationInfos/tag/AddTag.tsx b/src/renderer/common/components/dialog/publicationInfos/tag/AddTag.tsx index 5381a16026..4e9a29d26d 100644 --- a/src/renderer/common/components/dialog/publicationInfos/tag/AddTag.tsx +++ b/src/renderer/common/components/dialog/publicationInfos/tag/AddTag.tsx @@ -49,7 +49,13 @@ class AddTag extends React.Component { const { __, tagArray: tagArrayPublication, tags: allTagsLocally } = this.props; - const tagsOptions = allTagsLocally.filter((name) => !(tagArrayPublication || []).includes(name as any)).map((v, i) => ({ id: i, value: i, name: v })); + if (!__) { + return <>; + } + + const tagsOptions = (allTagsLocally ?? []).filter( + (name) => !(tagArrayPublication ?? []).includes(name as any), + ).map((v, i) => ({ id: i, value: i, name: v })); return ( this.props.pubId diff --git a/src/renderer/common/components/dialog/publicationInfos/tag/tagButton.tsx b/src/renderer/common/components/dialog/publicationInfos/tag/tagButton.tsx index fa16d75d0d..d77876e3a9 100644 --- a/src/renderer/common/components/dialog/publicationInfos/tag/tagButton.tsx +++ b/src/renderer/common/components/dialog/publicationInfos/tag/tagButton.tsx @@ -44,7 +44,7 @@ export const TagButton: React.FC> = (props) => { tagString = tag.name; } - if (pubId && onClickDeleteCb) { + if (pubId && onClickDeleteCb && typeof index === "number") { button = ( <> ; } + const publicationViewMaybeOpds = props.publicationViewMaybeOpds; + let controlsComponent = () => (<>); if (props.isOpds) { @@ -224,10 +226,10 @@ const PublicationInfoWithRadixContent = (props: {publicationViewMaybeOpds: TPubl return ( setCoverZoom(!coverZoom)} ControlComponent={controlsComponent} TagManagerComponent={TagManager} @@ -235,16 +237,19 @@ const PublicationInfoWithRadixContent = (props: {publicationViewMaybeOpds: TPubl onClickLinkCb={ (_link) => () => { const textObj = _link.nameLangString; - const pubLangs = props.publicationViewMaybeOpds.languages; + const pubLangs = publicationViewMaybeOpds.languages; const pubLang = pubLangs ? pubLangs[0] : undefined; // TODO: OPF xml:lang on title meta is actually the lang, not the declared pub lang(s)! const textObj_ = pubLang && typeof textObj === "string" ? { [pubLang]: textObj } : textObj; - return link(_link.link[0], location, convertMultiLangStringToString(textObj_, locale)); + const contributorLink = _link.link?.[0]; + return contributorLink + ? link(contributorLink, location, convertMultiLangStringToString(textObj_, locale)) + : undefined; } } focusWhereAmI={false} pdfPlayerNumberOfPages={undefined} divinaNumberOfPages={undefined} - divinaContinousEqualTrue={undefined} + divinaContinousEqualTrue={false} readerReadingLocation={undefined} closeDialogCb={props.closeDialog} > diff --git a/src/renderer/library/components/dialog/publicationInfos/TagManager.tsx b/src/renderer/library/components/dialog/publicationInfos/TagManager.tsx index 7e450d8bdd..ecf345ab5a 100644 --- a/src/renderer/library/components/dialog/publicationInfos/TagManager.tsx +++ b/src/renderer/library/components/dialog/publicationInfos/TagManager.tsx @@ -55,62 +55,85 @@ class TagManager extends React.Component { } public render(): React.ReactElement<{}> { - const { __ } = this.props; + const { __, location, publication, pubId, tagArray } = this.props; + + if (!__) { + return <>; + } const setTagsCb = - (tagsArray: string[]) => - this.props.setTags( - this.props.pubId, - this.props.publication as PublicationView, - tagsArray, - ); + (tagsArray: string[]) => { + if (publication && pubId) { + this.props.setTags( + pubId, + publication, + tagsArray, + ); + } + }; const updateTagsCb = (index: number) => () => - deleteTag(this.props.tagArray, setTagsCb)(index); + deleteTag(tagArray, setTagsCb)(index); return (
-

{__("catalog.tags")} {this.props.tagArray?.length > 0 ? ":" : ""}

- +

{__("catalog.tags")} {tagArray.length > 0 ? ":" : ""}

+ { (tag, index) => () => { - this.props.link(_tag.link[0], this.props.location, _tag.name); + const tagLink = _tag.link?.[0]; + if (tagLink && location) { + this.props.link(tagLink, location, _tag.name); + } } } - location={this.props.location} + location={location} > // }
- + { + publication && pubId + ? + : undefined + }
); } } -const mapStateToProps = (state: ILibraryRootState) => ({ - tagArray: (state.dialog.data as DialogType[DialogTypeName.PublicationInfoLib])?.publication?.tags, - pubId: (state.dialog.data as DialogType[DialogTypeName.PublicationInfoLib])?.publication?.identifier, - publication: (state.dialog.data as DialogType[DialogTypeName.PublicationInfoLib])?.publication, - location: state.router.location, - locale: state.i18n.locale, // refresh -}); +const mapStateToProps = (state: ILibraryRootState) => { + const publication = state.dialog.type === DialogTypeName.PublicationInfoLib + ? (state.dialog.data as DialogType[DialogTypeName.PublicationInfoLib])?.publication + : undefined; + const opdsPublication = state.dialog.type === DialogTypeName.PublicationInfoOpds + ? (state.dialog.data as DialogType[DialogTypeName.PublicationInfoOpds])?.publication + : undefined; + + return { + tagArray: publication?.tags ?? opdsPublication?.tags ?? [], + pubId: publication?.identifier, + publication, + location: state.router.location, + locale: state.i18n.locale, // refresh + }; +}; const mapDispatchToProps = (dispatch: TDispatch, _props: IBaseProps) => ({ setTags: (pubId: string, publication: PublicationView, tagsName: string[]) => { diff --git a/src/renderer/reader/components/dialog/publicationInfos/TagManager.tsx b/src/renderer/reader/components/dialog/publicationInfos/TagManager.tsx index 677a1a0c86..8456fbab93 100644 --- a/src/renderer/reader/components/dialog/publicationInfos/TagManager.tsx +++ b/src/renderer/reader/components/dialog/publicationInfos/TagManager.tsx @@ -56,13 +56,17 @@ export class TagManager extends React.Component { } public render(): React.ReactElement<{}> { - const { __ } = this.props; + const { __, publication, pubId, tagArray } = this.props; + + if (!__ || !publication || !pubId) { + return <>; + } const setTagsCb = (tagsArray: string[]) => this.props.setTags( - this.props.pubId, - this.props.publication as PublicationView, + pubId, + publication, tagsArray, ); @@ -75,13 +79,13 @@ export class TagManager extends React.Component { return (
-

{__("catalog.tags")} {this.props.tagArray.length > 0 ? ":" : ""}

- +

{__("catalog.tags")} {tagArray.length > 0 ? ":" : ""}

+ { (tag) => @@ -89,8 +93,8 @@ export class TagManager extends React.Component {
@@ -99,7 +103,7 @@ export class TagManager extends React.Component { } const mapStateToProps = (state: IReaderRootState) => ({ - tagArray: (state.dialog.data as DialogType[DialogTypeName.PublicationInfoReader])?.publication?.tags, + tagArray: (state.dialog.data as DialogType[DialogTypeName.PublicationInfoReader])?.publication?.tags ?? [], pubId: (state.dialog.data as DialogType[DialogTypeName.PublicationInfoReader])?.publication?.identifier, publication: (state.dialog.data as DialogType[DialogTypeName.PublicationInfoReader])?.publication, locale: state.i18n.locale, // refresh @@ -109,7 +113,7 @@ const mapDispatchToProps = (dispatch: TDispatch) => ({ setTags: (pubId: string, publication: PublicationView, tagsName: string[]) => { apiDispatch(dispatch)()("publication/updateTags")(pubId, tagsName); dispatch( - dialogActions.updateRequest.build( + dialogActions.updateRequest.build( { publication: { ...publication, diff --git a/test/main/converter/tools/publicationViewFallback.test.ts b/test/main/converter/tools/publicationViewFallback.test.ts new file mode 100644 index 0000000000..9b0b4e4ff1 --- /dev/null +++ b/test/main/converter/tools/publicationViewFallback.test.ts @@ -0,0 +1,119 @@ +// ==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 { describe, expect, it } from "@jest/globals"; + +import type { IOpdsPublicationView } from "readium-desktop/common/views/opds"; +import type { PublicationView } from "readium-desktop/common/views/publication"; +import { applyOpdsPublicationViewFallback } from "readium-desktop/main/converter/tools/publicationViewFallback"; + +const publicationView = (metadata: Partial = {}): PublicationView => ({ + identifier: "local-publication-id", + isOpenable: true, + readingFinished: false, + documentTitle: "-", + publicationTitle: "", + publicationSubTitle: "", + authorsLangString: [], + ...metadata, +}); + +const opdsPublicationView = (): IOpdsPublicationView => ({ + baseUrl: "https://example.com/catalog.json", + documentTitle: "OPDS title", + authorsLangString: [{ nameLangString: "OPDS author", link: [] }], + publishersLangString: [{ nameLangString: { en: "OPDS publisher" }, link: [] }], + workIdentifier: "opds-work-id", + description: "OPDS description", + numberOfPages: 42, + tags: [{ name: "OPDS subject", link: [] }], + languages: ["en"], + publishedAt: "2026-09-28T00:00:00.000Z", + duration: 3600, + nbOfTracks: 12, + a11y_accessMode: ["textual"], + a11y_accessibilityFeature: ["tableOfContents"], + a11y_accessibilityHazard: ["none"], + a11y_certifiedBy: ["OPDS certifier"], + a11y_certifierCredential: ["OPDS credential"], + a11y_certifierReport: ["https://example.com/report"], + a11y_conformsTo: ["EPUB Accessibility 1.1 - WCAG 2.2 Level AA"], + a11y_accessModeSufficient: [["textual"]], + a11y_accessibilitySummary: { en: "OPDS accessibility summary" }, + catalogLinkView: [], +}); + +describe("applyOpdsPublicationViewFallback", () => { + it("fills missing local metadata from the persisted OPDS publication", () => { + const result = applyOpdsPublicationViewFallback(publicationView(), opdsPublicationView()); + + expect(result).toMatchObject({ + documentTitle: "OPDS title", + publicationTitle: "OPDS title", + authorsLangString: ["OPDS author"], + publishersLangString: [{ en: "OPDS publisher" }], + workIdentifier: "opds-work-id", + description: "OPDS description", + tags: ["OPDS subject"], + languages: ["en"], + publishedAt: "2026-09-28T00:00:00.000Z", + duration: 3600, + nbOfTracks: 12, + a11y_accessMode: ["textual"], + a11y_accessibilityFeature: ["tableOfContents"], + a11y_accessibilityHazard: ["none"], + a11y_certifiedBy: ["OPDS certifier"], + a11y_certifierCredential: ["OPDS credential"], + a11y_certifierReport: ["https://example.com/report"], + a11y_conformsTo: ["EPUB Accessibility 1.1 - WCAG 2.2 Level AA"], + a11y_accessModeSufficient: [["textual"]], + a11y_accessibilitySummary: { en: "OPDS accessibility summary" }, + }); + }); + + it("keeps local publication metadata ahead of OPDS fallback metadata", () => { + const localMetadata = publicationView({ + documentTitle: "Local title", + publicationTitle: { en: "Local publication title" }, + authorsLangString: ["Local author"], + publishersLangString: ["Local publisher"], + workIdentifier: "local-work-id", + description: "Local description", + tags: ["Local subject"], + languages: ["fr"], + publishedAt: "2020-01-01T00:00:00.000Z", + duration: 120, + nbOfTracks: 2, + a11y_accessMode: ["auditory"], + a11y_accessibilitySummary: "Local accessibility summary", + }); + + const result = applyOpdsPublicationViewFallback(localMetadata, opdsPublicationView()); + + expect(result).toMatchObject({ + documentTitle: "Local title", + publicationTitle: { en: "Local publication title" }, + authorsLangString: ["Local author"], + publishersLangString: ["Local publisher"], + workIdentifier: "local-work-id", + description: "Local description", + tags: ["Local subject"], + languages: ["fr"], + publishedAt: "2020-01-01T00:00:00.000Z", + duration: 120, + nbOfTracks: 2, + a11y_accessMode: ["auditory"], + a11y_accessibilitySummary: "Local accessibility summary", + }); + }); + + it("returns the original view when there is no persisted OPDS publication", () => { + const localMetadata = publicationView(); + + expect(applyOpdsPublicationViewFallback(localMetadata)).toBe(localMetadata); + }); +}); diff --git a/test/main/tools/publicationDocument.test.ts b/test/main/tools/publicationDocument.test.ts index ad81244a7e..9eab15daf8 100644 --- a/test/main/tools/publicationDocument.test.ts +++ b/test/main/tools/publicationDocument.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "@jest/globals"; import { RandomCustomCovers } from "readium-desktop/common/models/custom-cover"; import { File } from "readium-desktop/common/models/file"; -import { buildPublicationFilesDocumentPatch } from "readium-desktop/main/tools/publicationDocument"; +import type { IOpdsLinkView, IOpdsPublicationView } from "readium-desktop/common/views/opds"; +import { + buildOpdsPublicationDocumentPatch, + buildPublicationFilesDocumentPatch, +} from "readium-desktop/main/tools/publicationDocument"; const publicationFile = (url: string, contentType: string, ext: string): File => ({ contentType, @@ -66,4 +70,35 @@ describe("publicationDocument", () => { files: [book], }); }); + + it("persists the complete OPDS publication view with its acquisition identity", () => { + const link: IOpdsLinkView = { + url: "https://example.com/publication.epub", + type: "application/epub+zip", + }; + const opdsPublication: IOpdsPublicationView = { + baseUrl: "https://example.com/catalog.json", + documentTitle: "An OPDS publication", + authorsLangString: [], + workIdentifier: "work-id", + numberOfPages: 0, + catalogLinkView: [], + opdsPublicationStringified: JSON.stringify({ metadata: { identifier: "work-id" } }), + selfLink: { + url: "https://example.com/publication.json", + type: "application/opds-publication+json", + }, + }; + + expect(buildOpdsPublicationDocumentPatch(link, opdsPublication)).toEqual({ + opdsPublicationStringified: opdsPublication.opdsPublicationStringified, + opdsPublicationView: opdsPublication, + opdsPublication: { + url: link.url, + type: link.type, + selfLinkUrl: opdsPublication.selfLink.url, + identifier: opdsPublication.workIdentifier, + }, + }); + }); });