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
9 changes: 7 additions & 2 deletions packages/demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { createHashRouter, RouterProvider } from "react-router";
import { I18nProvider } from "./i18n/I18nProvider";
import { Layout } from "./layout/Layout";
import { Loader } from "./layout/Loader";
import { ArkViewer, arkViewerLoader } from "./modules/ark-viewer/ArkViewer";
import {
ArkViewer,
ArkViewerErrorBoundary,
arkViewerLoader,
} from "./modules/ark-viewer/ArkViewer";
import { FileViewer } from "./modules/file-viewer/FileViewer";
import theme from "./theme";

Expand All @@ -20,10 +24,11 @@ const router = createHashRouter([
},

{
path: "ark/:id",
path: ":ark",
loader: arkViewerLoader,
HydrateFallback: Loader,
Component: ArkViewer,
ErrorBoundary: ArkViewerErrorBoundary,
},
],
},
Expand Down
5 changes: 4 additions & 1 deletion packages/demo/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export const en: Translation = {
},
ark: {
documentNotFound: "The requested document was not found.",
goToIstexSearch: "Return to Istex Search",
},
errors: {
DocumentNotFoundError: "No documents match this identifier.",
NoFulltextError: "Couldn't get the fulltext in TEI format.",
},
};
6 changes: 5 additions & 1 deletion packages/demo/src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export const fr = {
},
ark: {
documentNotFound: "Le document demandé n'a pas été trouvé.",
goToIstexSearch: "Retourner sur Istex Search",
},
errors: {
DocumentNotFoundError: "Aucun document ne correspond à cet identifiant.",
NoFulltextError:
"La récupération du texte intégral au format TEI a échoué.",
},
};

Expand Down
99 changes: 52 additions & 47 deletions packages/demo/src/modules/ark-viewer/ArkViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Viewer } from "@istex/react-tei/Viewer.js";
import KeyboardBackspaceIcon from "@mui/icons-material/KeyboardBackspace";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Container from "@mui/material/Container";
import Link from "@mui/material/Link";
import { Alert, Container } from "@mui/material";
import { useTranslation } from "react-i18next";
import { type LoaderFunction, useLoaderData } from "react-router";
import document from "./document/minimal.tei.xml?raw";
import unitexEnrichment from "./document/minimal.unitex.xml?raw";
import {
type LoaderFunction,
useLoaderData,
useRouteError,
} from "react-router";
import {
getDocumentInfo,
getEnrichment,
getFulltext,
TranslatedError,
} from "./utils";

export type ArkViewerLoaderData = {
document: string | null;
document: string;
unitexEnrichment?: string | null;
teeftEnrichment?: string | null;
multicatEnrichment?: string | null;
Expand All @@ -20,67 +24,68 @@ export type ArkViewerLoaderData = {
export const arkViewerLoader: LoaderFunction = async ({
params,
}): Promise<ArkViewerLoaderData> => {
const { id } = params;
const { ark } = params;
if (!ark) {
throw new Error("Missing ARK, this should not happen.");
}

const documentInfo = await getDocumentInfo(ark);

/**
* Simulate loading time
* @todo In a real app, you would fetch the document by its id here
*/
await new Promise((resolve) => setTimeout(resolve, 1000));
const promisePool = [
getFulltext(documentInfo),
getEnrichment(documentInfo, "unitex"),
getEnrichment(documentInfo, "teeft"),
getEnrichment(documentInfo, "multicat"),
getEnrichment(documentInfo, "nb"),
];
const [fulltextResult, unitexResult, teeftResult, multicatResult, nbResult] =
await Promise.allSettled(promisePool);

if (!id) {
return {
document: null,
};
// Errors while getting the fulltext are considered fatal so we rethrow them
if (fulltextResult?.status === "rejected") {
throw fulltextResult.reason;
}

return {
document,
unitexEnrichment,
document: fulltextResult?.value ?? "",
unitexEnrichment:
unitexResult?.status === "fulfilled" ? unitexResult.value : null,
teeftEnrichment:
teeftResult?.status === "fulfilled" ? teeftResult.value : null,
multicatEnrichment:
multicatResult?.status === "fulfilled" ? multicatResult.value : null,
nbEnrichment: nbResult?.status === "fulfilled" ? nbResult.value : null,
};
};

export function ArkViewer() {
const { document, ...rest } = useLoaderData<ArkViewerLoaderData>();

if (!document) {
return <DocumentNotFound />;
}
const data = useLoaderData<ArkViewerLoaderData>();

return <Viewer document={document} stickyTopOffset={36} {...rest} />;
return <Viewer stickyTopOffset={36} {...data} />;
}

function DocumentNotFound() {
export function ArkViewerErrorBoundary() {
const { t } = useTranslation();
const error = useRouteError();

const errorText = !(error instanceof Error)
? "Unknown error"
: error instanceof TranslatedError
? t(error.translationKey, error.translationData)
: error.message;

return (
<Container
maxWidth="sm"
sx={{
pt: 5,
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: 2,
flexGrow: 1,
}}
>
<Alert
severity="warning"
sx={{
width: "100%",
}}
>
{t("ark.documentNotFound")}
</Alert>
<Button
component={Link}
href="https://search.istex.fr/fr-FR"
fullWidth
variant="contained"
startIcon={<KeyboardBackspaceIcon />}
>
{t("ark.goToIstexSearch")}
</Button>
<Alert severity="error">{errorText}</Alert>
</Container>
);
}
129 changes: 129 additions & 0 deletions packages/demo/src/modules/ark-viewer/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
interface FileInfo {
mimetype: string;
uri: string;
}

interface DocumentInfo {
fulltext?: FileInfo[];
enrichments?: {
multicat?: FileInfo[];
nb?: FileInfo[];
teeft?: FileInfo[];
unitex?: FileInfo[];
};
}

interface IstexApiResponse {
total: number;
hits: DocumentInfo[];
}

export async function getDocumentInfo(ark: string) {
const url = new URL("/document", "https://api.istex.fr");
url.searchParams.set("q", `arkIstex.raw:"${ark}"`);
url.searchParams.set("output", "fulltext,enrichments");

const response = await fetch(url);
if (!response.ok) {
console.error(
`Couldn't get the document info, the API responded with status ${response.status}.`,
);
throw new DocumentNotFoundError();
}

const body = (await response.json()) as IstexApiResponse;
if (body.total === 0 || body.hits[0] == null) {
console.error("Couldn't get the document info, the hits array is empty.");
throw new DocumentNotFoundError();
}

return body.hits[0];
}

export async function getFulltext(documentInfo: DocumentInfo) {
const info = documentInfo.fulltext?.find(
(fulltext) => fulltext.mimetype === "application/tei+xml",
);
if (!info) {
console.error(
"Couldn't get the fulltext, the fulltext array has no entries with 'mimetype' set to 'application/tei+xml'.",
);
throw new NoFulltextError();
}

// If getting the fulltext fails, we rethrow a different error so that
// it can be displayed nicely in the UI
try {
return await getProtectedResource(info.uri);
} catch {
throw new NoFulltextError();
}
}

export async function getEnrichment(
documentInfo: DocumentInfo,
enrichmentName: keyof NonNullable<DocumentInfo["enrichments"]>,
) {
const info = documentInfo.enrichments?.[enrichmentName]?.find(
(enrichment) => enrichment.mimetype === "application/tei+xml",
);
if (!info) {
throw new Error(`Enrichment ${enrichmentName} not found`);
}

return await getProtectedResource(info.uri);
}

async function getProtectedResource(url: string) {
const response = await fetch(url, {
// Include the Istex API session cookie
credentials: "include",

// The API redirects to the login page when no session cookies are set.
// We don't follow this redirect because we will set the current page (Istex View)
// to this login page instead
redirect: "manual",
});
if (!response.ok) {
if (response.type === "opaqueredirect") {
// When we get redirected, we craft the login page URL ourselves and set the callback
// URL (target search param) to the current page (Istex View)
const url = new URL("/authFede/", "https://api.istex.fr");
url.searchParams.set("target", window.location.href);
window.location.href = url.toString();
} else {
// If the response was not OK and was not a redirect, it's a real error
const errorMessage = `Couldn't access the protected resource at '${url}', the API responded with status ${response.status}.`;
console.error(errorMessage);
throw new Error(errorMessage);
}
}

return await response.text();
}

export class TranslatedError extends Error {
translationKey: string;
translationData: Record<string, unknown> | undefined;

constructor(
translationKey: string,
translationData?: Record<string, unknown>,
) {
super();
this.translationKey = translationKey;
this.translationData = translationData;
}
}

export class DocumentNotFoundError extends TranslatedError {
constructor() {
super("errors.DocumentNotFoundError");
}
}

export class NoFulltextError extends TranslatedError {
constructor() {
super("errors.NoFulltextError");
}
}
8 changes: 7 additions & 1 deletion packages/demo/src/modules/file-viewer/FileViewer.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { I18nProvider } from "../../i18n/I18nProvider";
import { FileViewer } from "./FileViewer";

vi.mock("react-router", () => ({
...vi.importActual("react-router"),
useNavigate: () => vi.fn(),
useNavigation: () => ({}),
}));

describe("FileViewer", () => {
it("should render the upload button", async () => {
const screen = await render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { render } from "vitest-browser-react";
import { ViewerContextProvider } from "../viewer/ViewerContext";
import { UploadPage } from "./UploadPage";

vi.mock("react-router", () => ({
...vi.importActual("react-router"),
useNavigate: () => vi.fn(),
useNavigation: () => ({}),
}));

vi.mock("../viewer/useViewerContext");

describe("UploadPage", () => {
Expand Down
Loading
Loading