Compare commits

..

2 Commits

Author SHA1 Message Date
astanley 0c862b9785 added View All Annotations tab 2 weeks ago
astanley 6d94af55de added View All Annotations tab 2 weeks ago
  1. 70
      README.md
  2. 592
      build/src/AllAnnotationsPanel.jsx
  3. 26
      build/src/index.js
  4. 9
      build/vite.config.js
  5. 234
      dist/main.js
  6. 61
      islandora_mirador_annotations.install
  7. 9
      islandora_mirador_annotations.libraries.yml
  8. 12
      islandora_mirador_annotations.module
  9. 8
      islandora_mirador_annotations.routing.yml
  10. 105
      src/Controller/AnnotationController.php

70
README.md

@ -2,34 +2,32 @@
Standalone Drupal add-on for `islandora_mirador` that adds persistent Mirador 4 annotations using Mirador Annotation Editor (MAE).
## Features
- Leaves the base `islandora_mirador` module untouched.
- Drupal-backed persistent Web Annotations.
- Existing MAE per-canvas annotation editor/sidebar.
- **All annotations** sidebar for multi-page manifests.
- All-annotations results are grouped by canvas/page label and searchable.
- Clicking an annotation in the all-annotations panel jumps Mirador to that canvas.
- A single Drupal query loads annotations for the complete manifest; it does not make one Drupal request per page.
## Architecture
The base `islandora_mirador` module is not patched. This module:
This module alters the base viewer library dependency so the viewer loads this module's annotated Mirador bundle. The bundle contains Mirador, the normal Islandora image-tools/text-overlay/download plugins, MAE, a Drupal persistence adapter, and the All Annotations companion-window plugin.
1. Alters the base viewer library dependency so the viewer loads this module's annotated Mirador bundle.
2. Builds that bundle from the same Mirador 4 plugins used by Islandora plus MAE.
3. Excludes MAE's currently incompatible `annotationSagaPlugin` and replaces only its annotation-loading behavior.
4. Adds a Drupal-backed MAE adapter.
5. Stores each Web Annotation as JSON in `islandora_mirador_annotation`.
6. Exposes an IIIF `AnnotationPage` at `/islandora-mirador/annotations?canvas=...`.
7. Protects POST/PATCH/DELETE with Drupal permissions and `X-CSRF-Token`.
MAE's incompatible `annotationSagaPlugin` is excluded and its canvas-loading behavior is replaced locally.
## Install
Place this directory next to `islandora_mirador`, then enable it:
```bash
drush en islandora_mirador_annotations -y
drush cr
```
Grant permissions as appropriate. For example, to let anonymous and authenticated users see annotations, grant `view mirador annotations` to those roles. Grant `manage mirador annotations` only to roles that may create/edit/delete annotations.
The module automatically sets MAE to read-only when the current user does not have `manage mirador annotations`.
Grant `view mirador annotations` to roles that may see annotations and `manage mirador annotations` to roles that may create/edit/delete them.
## Build `dist/main.js`
The build project is retained under `build/`.
## Build assets
```bash
cd build
@ -37,30 +35,38 @@ npm install
npm run build
```
Vite writes the bundle to `../dist/main.js`.
The bundle intentionally contains Mirador itself, the Islandora image-tools/text-overlay/download plugins, and MAE in one dependency graph. This avoids loading a second React/MUI runtime beside the base Mirador bundle.
Vite writes:
## Base module
```text
dist/main.js
dist/main.css
```
No changes to `islandora_mirador` are required. When this module is disabled, the base viewer returns to its normal `islandora_mirador/mirador` dependency after a cache rebuild.
Both compiled files should be committed to the module repository so Composer users do not need Node/npm/Vite.
## Endpoint
## Endpoints
GET:
Current-canvas AnnotationPage:
```text
/islandora-mirador/annotations?canvas=<canvas URI>
GET /islandora-mirador/annotations?canvas=<canvas URI>
```
returns:
Manifest-wide read endpoint used by the All Annotations panel:
```text
POST /islandora-mirador/annotations/manifest
Content-Type: application/json
```json
{
"id": "...",
"type": "AnnotationPage",
"items": []
}
{"canvasIds":["https://.../canvas/1","https://.../canvas/2"]}
```
MAE writes through the same endpoint using POST, PATCH, and DELETE.
Annotation writes use POST/PATCH/DELETE on `/islandora-mirador/annotations` and are protected by Drupal permissions and `X-CSRF-Token`.
## All annotations click-to-focus
Selecting an entry in the **All annotations** panel switches to its canvas, selects the Mirador annotation so its target is highlighted, and, when the annotation contains an `xywh` FragmentSelector, zooms OpenSeadragon to that region with a small amount of padding. Both pixel and percent `xywh` selectors are supported.
### 0.2.5 annotation targeting
The All Annotations panel uses the Web Annotation `target.source` as the authoritative canvas when it differs from the persistence row's canvas id. It also zooms to MAE `SvgSelector` geometry when an `xywh` FragmentSelector is not available.

592
build/src/AllAnnotationsPanel.jsx

@ -0,0 +1,592 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
ConnectedCompanionWindow as CompanionWindow,
OSDReferences,
selectAnnotation as selectAnnotationAction,
setCanvas as setCanvasAction,
updateWindow as updateWindowAction,
} from 'mirador';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SvgIcon from '@mui/material/SvgIcon';
const PANEL_KEY = 'allAnnotations';
function AllAnnotationsIcon(props) {
return (
<SvgIcon {...props} viewBox="0 0 24 24">
<path d="M4 5h3v3H4V5zm5 0h11v3H9V5zM4 10.5h3v3H4v-3zm5 0h11v3H9v-3zM4 16h3v3H4v-3zm5 0h11v3H9v-3z" />
</SvgIcon>
);
}
function getLabel(label, fallback) {
if (!label) return fallback;
if (typeof label === 'string') return label;
if (Array.isArray(label)) return label.join(', ');
if (label.en?.length) return label.en.join(', ');
const first = Object.values(label).find((value) => Array.isArray(value) && value.length);
return first ? first.join(', ') : fallback;
}
function canvasesFromManifest(manifest) {
if (Array.isArray(manifest?.items)) {
return manifest.items
.filter((item) => item?.type === 'Canvas' || item?.id)
.map((canvas, index) => ({
id: canvas.id,
label: getLabel(canvas.label, `Page ${index + 1}`),
index,
width: canvas.width,
height: canvas.height,
}))
.filter((canvas) => canvas.id);
}
const canvases = manifest?.sequences?.[0]?.canvases || [];
return canvases.map((canvas, index) => ({
id: canvas['@id'] || canvas.id,
label: getLabel(canvas.label, `Page ${index + 1}`),
index,
width: canvas.width,
height: canvas.height,
})).filter((canvas) => canvas.id);
}
function annotationTargetCanvasId(annotation) {
const target = annotation?.target;
if (typeof target === 'string') {
return target.split('#')[0] || null;
}
const source = target?.source;
if (typeof source === 'string') return source.split('#')[0] || null;
if (source?.id) return String(source.id).split('#')[0] || null;
if (source?.['@id']) return String(source['@id']).split('#')[0] || null;
if (target?.id) return String(target.id).split('#')[0] || null;
if (target?.['@id']) return String(target['@id']).split('#')[0] || null;
return null;
}
function selectorObjects(annotation) {
const target = annotation?.target;
if (!target || typeof target === 'string') return [];
if (Array.isArray(target.selector)) return target.selector;
return target.selector ? [target.selector] : [];
}
function svgSelectorValue(annotation) {
const selector = selectorObjects(annotation).find((item) =>
item?.type === 'SvgSelector' || item?.['@type'] === 'oa:SvgSelector');
return typeof selector?.value === 'string' ? selector.value : '';
}
function svgBounds(annotation) {
const value = svgSelectorValue(annotation);
if (!value || typeof DOMParser === 'undefined' || !document?.body) return null;
let host;
try {
const parsed = new DOMParser().parseFromString(value, 'image/svg+xml');
const parsedSvg = parsed.documentElement;
if (!parsedSvg || parsedSvg.nodeName.toLowerCase() !== 'svg') return null;
host = document.createElement('div');
host.setAttribute('aria-hidden', 'true');
host.style.cssText = [
'position:absolute',
'left:-100000px',
'top:-100000px',
'width:1px',
'height:1px',
'visibility:hidden',
'overflow:hidden',
'pointer-events:none',
].join(';');
const svg = document.importNode(parsedSvg, true);
host.appendChild(svg);
document.body.appendChild(host);
const drawable = svg.querySelectorAll('path,rect,circle,ellipse,polygon,polyline,line');
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
drawable.forEach((node) => {
if (typeof node.getBBox !== 'function') return;
try {
const box = node.getBBox();
if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) return;
if (box.width <= 0 && box.height <= 0) return;
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
maxX = Math.max(maxX, box.x + box.width);
maxY = Math.max(maxY, box.y + box.height);
}
catch (_error) {
// Ignore an individual SVG element that cannot report a bounding box.
}
});
if (![minX, minY, maxX, maxY].every(Number.isFinite) || maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
catch (error) {
console.warn('Unable to read Mirador SVG annotation bounds.', error);
return null;
}
finally {
if (host?.parentNode) host.parentNode.removeChild(host);
}
}
function selectorValue(annotation) {
const target = annotation?.target;
if (typeof target === 'string') {
const hash = target.indexOf('#');
return hash >= 0 ? target.slice(hash + 1) : '';
}
const selectors = Array.isArray(target?.selector)
? target.selector
: target?.selector
? [target.selector]
: [];
for (const selector of selectors) {
if (typeof selector === 'string' && selector.includes('xywh=')) {
return selector;
}
if (typeof selector?.value === 'string' && selector.value.includes('xywh=')) {
return selector.value;
}
}
return '';
}
function annotationBounds(annotation, canvas) {
const value = selectorValue(annotation);
const match = value.match(/(?:^|[#&])?xywh=(?:(pixel|percent):)?([0-9.]+),([0-9.]+),([0-9.]+),([0-9.]+)/i);
let bounds = null;
if (match) {
let [, unit, x, y, width, height] = match;
x = Number(x);
y = Number(y);
width = Number(width);
height = Number(height);
if ([x, y, width, height].every(Number.isFinite) && width > 0 && height > 0) {
if (unit?.toLowerCase() === 'percent') {
if (canvas?.width && canvas?.height) {
x = (x / 100) * canvas.width;
y = (y / 100) * canvas.height;
width = (width / 100) * canvas.width;
height = (height / 100) * canvas.height;
}
else {
return null;
}
}
bounds = { x, y, width, height };
}
}
// MAE commonly saves drawn regions as an SvgSelector and, in some cases,
// the accompanying FragmentSelector contains only the canvas URI. Use the
// SVG geometry as the zoom target when no xywh fragment is available.
if (!bounds) bounds = svgBounds(annotation);
if (!bounds) return null;
const paddingX = bounds.width * 0.15;
const paddingY = bounds.height * 0.15;
return {
x: Math.max(0, bounds.x - paddingX),
y: Math.max(0, bounds.y - paddingY),
width: bounds.width + (paddingX * 2),
height: bounds.height + (paddingY * 2),
};
}
function viewerMatchesCanvas(tiledImage, canvas) {
if (!tiledImage || !canvas?.width || !canvas?.height) return Boolean(tiledImage);
try {
const size = tiledImage.getContentSize?.();
if (!size?.x || !size?.y) return false;
// IIIF/OpenSeadragon dimensions should normally be exact, but allow a
// tiny tolerance for implementations that round image dimensions.
const widthMatches = Math.abs(size.x - canvas.width) <= 2;
const heightMatches = Math.abs(size.y - canvas.height) <= 2;
return widthMatches && heightMatches;
}
catch (_error) {
return false;
}
}
function focusAnnotationWhenViewerReady(
windowId,
annotation,
canvas,
selectAnnotation,
updateWindow,
attempt = 0,
) {
const bounds = annotationBounds(annotation, canvas);
const osdRef = OSDReferences.get(windowId);
const viewer = osdRef?.current;
const tiledImage = viewer?.world?.getItemAt?.(0);
const ready = viewer?.viewport
&& tiledImage
&& viewerMatchesCanvas(tiledImage, canvas);
if (!ready) {
if (attempt < 50) {
window.setTimeout(
() => focusAnnotationWhenViewerReady(
windowId,
annotation,
canvas,
selectAnnotation,
updateWindow,
attempt + 1,
),
100,
);
}
else {
console.warn('Timed out waiting for Mirador/OpenSeadragon to display the target canvas.');
}
return;
}
// Canvas changes can complete after the first Redux selection. Reassert the
// annotation state only after the new OSD image/overlay is actually ready.
updateWindow(windowId, { highlightAllAnnotations: false });
selectAnnotation(windowId, annotation.id);
const applyFocus = () => {
const currentRef = OSDReferences.get(windowId);
const currentViewer = currentRef?.current;
const currentImage = currentViewer?.world?.getItemAt?.(0);
if (!currentViewer?.viewport || !currentImage) return;
// Reassert selection after OSD has had a chance to replace the old canvas.
updateWindow(windowId, { highlightAllAnnotations: false });
selectAnnotation(windowId, annotation.id);
currentViewer.forceRedraw?.();
if (!bounds) return;
try {
const rect = currentImage.imageToViewportRectangle(
bounds.x,
bounds.y,
bounds.width,
bounds.height,
);
currentViewer.viewport.fitBounds(rect, false);
currentViewer.forceRedraw?.();
}
catch (error) {
console.warn('Unable to zoom to Mirador annotation.', error);
}
};
// The Redux canvas changes before OSD necessarily finishes replacing its world item.
// Apply immediately, then retry a few times. The later passes are intentionally harmless
// and ensure a single click works even when consecutive canvases have identical dimensions.
window.requestAnimationFrame(applyFocus);
[250, 600, 1100].forEach((delay) => window.setTimeout(applyFocus, delay));
}
function bodyText(body) {
const bodies = Array.isArray(body) ? body : body ? [body] : [];
for (const item of bodies) {
const value = item?.value ?? item?.chars ?? item?.label;
if (typeof value === 'string' && value.trim()) {
const tmp = document.createElement('div');
tmp.innerHTML = value;
return (tmp.textContent || tmp.innerText || value).trim();
}
}
return '';
}
function annotationText(annotation) {
return bodyText(annotation?.body)
|| annotation?.label
|| annotation?.motivation
|| 'Annotation';
}
function AllAnnotationsPanel({
id,
windowId,
manifestId,
endpoint,
selectedCanvasId,
selectedAnnotationId,
setCanvas,
selectAnnotation,
updateWindow,
}) {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
const [pendingFocus, setPendingFocus] = useState(null);
useEffect(() => {
let cancelled = false;
async function load() {
if (!manifestId || !endpoint) {
setGroups([]);
setLoading(false);
return;
}
setLoading(true);
setError('');
try {
const manifestResponse = await fetch(manifestId, {
credentials: 'same-origin',
headers: { Accept: 'application/json, application/ld+json' },
});
if (!manifestResponse.ok) {
throw new Error(`Unable to load manifest (${manifestResponse.status})`);
}
const manifest = await manifestResponse.json();
const canvases = canvasesFromManifest(manifest);
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ canvasIds: canvases.map((canvas) => canvas.id) }),
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Unable to load all annotations (${response.status})${detail ? `: ${detail}` : ''}`);
}
const payload = await response.json();
const byCanvas = new Map();
const manifestCanvasIds = new Set(canvases.map((canvas) => canvas.id));
(payload.items || []).forEach((item) => {
// The Web Annotation target is authoritative. This also repairs the
// display of older rows whose adapter canvas_id does not match the
// annotation's target.source.
const targetCanvasId = annotationTargetCanvasId(item.annotation);
const canvasId = targetCanvasId && manifestCanvasIds.has(targetCanvasId)
? targetCanvasId
: item.canvasId;
if (!byCanvas.has(canvasId)) byCanvas.set(canvasId, []);
byCanvas.get(canvasId).push(item.annotation);
});
const nextGroups = canvases
.map((canvas) => ({ ...canvas, annotations: byCanvas.get(canvas.id) || [] }))
.filter((canvas) => canvas.annotations.length > 0);
if (!cancelled) setGroups(nextGroups);
}
catch (e) {
if (!cancelled) setError(e?.message || String(e));
}
finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [manifestId, endpoint]);
useEffect(() => {
if (!pendingFocus || selectedCanvasId !== pendingFocus.canvas.id) return;
// Mirador selection state drives the visual highlight. It is safe to set
// this before the annotation page finishes loading; the overlay picks it
// up as soon as the persisted annotation is received into the store.
updateWindow(windowId, { highlightAllAnnotations: false });
selectAnnotation(windowId, pendingFocus.annotation.id);
// The Redux canvas id changes before OpenSeadragon has necessarily
// replaced the old tiled image. Wait until OSD is displaying the target
// canvas, then reassert selection, redraw the annotation overlay, and zoom.
focusAnnotationWhenViewerReady(
windowId,
pendingFocus.annotation,
pendingFocus.canvas,
selectAnnotation,
updateWindow,
);
setPendingFocus(null);
}, [pendingFocus, selectedCanvasId, selectAnnotation, updateWindow, windowId]);
const focusAnnotation = (canvas, annotation) => {
if (!annotation?.id) {
setCanvas(windowId, canvas.id);
return;
}
setPendingFocus({ canvas, annotation });
if (selectedCanvasId === canvas.id) {
updateWindow(windowId, { highlightAllAnnotations: false });
selectAnnotation(windowId, annotation.id);
focusAnnotationWhenViewerReady(
windowId,
annotation,
canvas,
selectAnnotation,
updateWindow,
);
setPendingFocus(null);
return;
}
setCanvas(windowId, canvas.id);
};
const filteredGroups = useMemo(() => {
const needle = filter.trim().toLowerCase();
if (!needle) return groups;
return groups.map((group) => ({
...group,
annotations: group.annotations.filter((annotation) =>
`${group.label} ${annotationText(annotation)}`.toLowerCase().includes(needle)),
})).filter((group) => group.annotations.length > 0);
}, [groups, filter]);
const total = groups.reduce((sum, group) => sum + group.annotations.length, 0);
return (
<CompanionWindow id={id} windowId={windowId} title={`All annotations (${total})`}>
<Box sx={{ p: 1.5 }}>
<TextField
fullWidth
size="small"
label="Search annotations"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
</Box>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress size={28} />
</Box>
)}
{!loading && error && (
<Typography color="error" sx={{ px: 2, py: 1 }} variant="body2">
{error}
</Typography>
)}
{!loading && !error && total === 0 && (
<Typography sx={{ px: 2, py: 1 }} variant="body2">
No annotations in this manifest.
</Typography>
)}
{!loading && !error && filteredGroups.map((group) => (
<Box key={group.id}>
<Typography
component="h3"
sx={{ px: 2, pt: 1.5, pb: 0.5, fontWeight: 600 }}
variant="subtitle2"
>
{group.label} ({group.annotations.length})
</Typography>
<List dense disablePadding>
{group.annotations.map((annotation, index) => (
<ListItemButton
key={annotation.id || `${group.id}-${index}`}
selected={selectedCanvasId === group.id && selectedAnnotationId === annotation.id}
onClick={() => focusAnnotation(group, annotation)}
sx={{ pl: 3, alignItems: 'flex-start' }}
>
<ListItemText
primary={annotationText(annotation)}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
))}
</List>
<Divider />
</Box>
))}
</CompanionWindow>
);
}
const allAnnotationsPanelPlugin = {
name: 'AllAnnotationsPanel',
companionWindowKey: PANEL_KEY,
component: AllAnnotationsPanel,
mapStateToProps: (state, { windowId }) => ({
manifestId: state?.windows?.[windowId]?.manifestId,
selectedCanvasId: state?.windows?.[windowId]?.canvasId,
selectedAnnotationId: state?.windows?.[windowId]?.selectedAnnotationId,
endpoint: state?.config?.annotation?.allEndpoint,
}),
mapDispatchToProps: (dispatch) => ({
setCanvas: (windowId, canvasId) => dispatch(setCanvasAction(windowId, canvasId)),
selectAnnotation: (windowId, annotationId) =>
dispatch(selectAnnotationAction(windowId, annotationId)),
updateWindow: (windowId, payload) =>
dispatch(updateWindowAction(windowId, payload)),
}),
};
const AllAnnotationsButton = () => <AllAnnotationsIcon fontSize="small" />;
AllAnnotationsButton.value = PANEL_KEY;
const allAnnotationsButtonPlugin = {
target: 'WindowSideBarButtons',
mode: 'add',
name: 'AllAnnotationsButton',
value: PANEL_KEY,
component: AllAnnotationsButton,
};
export { allAnnotationsPanelPlugin, allAnnotationsButtonPlugin };

26
build/src/index.js

@ -6,6 +6,26 @@ import miradorDownloadPlugins from 'mirador-dl-plugin';
import textOverlayPlugin from 'mirador-textoverlay';
import annotationPlugins from 'mirador-annotation-editor';
import 'mirador-annotation-editor/dist/index.css';
import { allAnnotationsPanelPlugin, allAnnotationsButtonPlugin } from './AllAnnotationsPanel.jsx';
function annotationTargetCanvasId(annotation) {
const target = annotation?.target;
if (typeof target === 'string') {
return target.split('#')[0] || null;
}
const source = target?.source;
if (typeof source === 'string') return source.split('#')[0] || null;
if (source?.id) return String(source.id).split('#')[0] || null;
if (source?.['@id']) return String(source['@id']).split('#')[0] || null;
if (target?.id) return String(target.id).split('#')[0] || null;
if (target?.['@id']) return String(target['@id']).split('#')[0] || null;
return null;
}
/**
* Drupal-backed implementation of the MAE persistence adapter contract.
@ -83,14 +103,14 @@ class DrupalAnnotationAdapter {
async create(annotation) {
return this.request('POST', {
canvasId: this.canvasId,
canvasId: annotationTargetCanvasId(annotation) || this.canvasId,
annotation,
});
}
async update(annotation) {
return this.request('PATCH', {
canvasId: this.canvasId,
canvasId: annotationTargetCanvasId(annotation) || this.canvasId,
annotation,
});
}
@ -114,6 +134,8 @@ const plugins = [
...miradorImageToolsPlugin,
...textOverlayPlugin,
...annotationUiPlugins,
allAnnotationsPanelPlugin,
allAnnotationsButtonPlugin,
...miradorDownloadPlugins,
];

9
build/vite.config.js

@ -3,10 +3,15 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
define: {
global: 'globalThis',
},
resolve: {
dedupe: ['@emotion/react', '@emotion/styled', 'react', 'react-dom'],
},
build: {
outDir: '../dist',
emptyOutDir: true,
lib: {
entry: 'src/index.js',
name: 'IslandoraMiradorAnnotations',
@ -14,9 +19,7 @@ export default defineConfig({
fileName: () => 'main.js',
cssFileName: 'main',
},
cssCodeSplit: false,
rollupOptions: {
output: {
inlineDynamicImports: true,

234
dist/main.js vendored

File diff suppressed because one or more lines are too long

61
islandora_mirador_annotations.install

@ -68,3 +68,64 @@ function islandora_mirador_annotations_schema() {
return $schema;
}
/**
* Repair rows whose stored canvas differs from the Web Annotation target.
*/
function islandora_mirador_annotations_update_9001() {
$database = \Drupal::database();
$result = $database->select('islandora_mirador_annotation', 'a')
->fields('a', ['id', 'canvas_id', 'annotation_json'])
->execute();
$updated = 0;
foreach ($result as $row) {
$annotation = json_decode((string) $row->annotation_json, TRUE);
if (!is_array($annotation)) {
continue;
}
$target = $annotation['target'] ?? NULL;
$target_canvas_id = NULL;
if (is_string($target)) {
$target_canvas_id = explode('#', $target, 2)[0];
}
elseif (is_array($target)) {
$source = $target['source'] ?? NULL;
if (is_string($source)) {
$target_canvas_id = explode('#', $source, 2)[0];
}
elseif (is_array($source)) {
$value = $source['id'] ?? $source['@id'] ?? NULL;
if (is_string($value)) {
$target_canvas_id = explode('#', $value, 2)[0];
}
}
if (!$target_canvas_id) {
$value = $target['id'] ?? $target['@id'] ?? NULL;
if (is_string($value)) {
$target_canvas_id = explode('#', $value, 2)[0];
}
}
}
if (!$target_canvas_id || $target_canvas_id === $row->canvas_id) {
continue;
}
$database->update('islandora_mirador_annotation')
->fields([
'canvas_id' => $target_canvas_id,
'canvas_key' => hash('sha256', $target_canvas_id),
])
->condition('id', $row->id)
->execute();
$updated++;
}
return t('Re-associated @count Mirador annotation(s) with their target canvas.', [
'@count' => $updated,
]);
}

9
islandora_mirador_annotations.libraries.yml

@ -1,10 +1,7 @@
annotated_mirador:
version: 0.1.0
version: 0.2.5
js:
dist/main.js:
minified: true
dist/main.js: { minified: true, preprocess: false }
css:
theme:
dist/main.css: {}
dist/main.css: { preprocess: false }

12
islandora_mirador_annotations.module

@ -45,11 +45,13 @@ function islandora_mirador_annotations_js_settings_alter(array &$settings, Attac
$endpoint = Url::fromRoute('islandora_mirador_annotations.collection', [], ['absolute' => TRUE])->toString();
$can_manage = $account->hasPermission('manage mirador annotations');
$all_endpoint = Url::fromRoute('islandora_mirador_annotations.manifest_collection', [], ['absolute' => TRUE])->toString();
foreach ($settings['mirador']['viewers'] as &$viewer) {
$viewer['annotation'] = [
'adapter' => 'drupal',
'endpoint' => $endpoint,
'allEndpoint' => $all_endpoint,
'csrfTokenUrl' => Url::fromRoute('system.csrftoken', [], ['absolute' => TRUE])->toString(),
'user' => $account->isAuthenticated() ? $account->getDisplayName() : 'Anonymous',
'readonly' => !$can_manage,
@ -63,5 +65,15 @@ function islandora_mirador_annotations_js_settings_alter(array &$settings, Attac
$viewer['window'] = $viewer['window'] ?? [];
$viewer['window']['defaultSideBarPanel'] = 'annotations';
$viewer['window']['sideBarOpenByDefault'] = TRUE;
// Keep Mirador's annotation overlay mounted even when our custom All annotations panel is open.
// Core otherwise only draws annotations while the stock annotations companion window is open.
$viewer['window']['forceDrawAnnotations'] = TRUE;
$viewer['window']['panels'] = $viewer['window']['panels'] ?? [];
$viewer['window']['panels']['allAnnotations'] = TRUE;
$viewer['translations'] = $viewer['translations'] ?? [];
$viewer['translations']['en'] = $viewer['translations']['en'] ?? [];
$viewer['translations']['en']['openCompanionWindow_allAnnotations'] = 'All annotations';
}
}

8
islandora_mirador_annotations.routing.yml

@ -32,3 +32,11 @@ islandora_mirador_annotations.delete:
_permission: 'manage mirador annotations'
_csrf_request_header_token: 'TRUE'
methods: [DELETE]
islandora_mirador_annotations.manifest_collection:
path: '/islandora-mirador/annotations/manifest'
defaults:
_controller: '\Drupal\islandora_mirador_annotations\Controller\AnnotationController::manifestCollection'
requirements:
_permission: 'view mirador annotations'
methods: [POST]

105
src/Controller/AnnotationController.php

@ -51,6 +51,66 @@ final class AnnotationController extends ControllerBase {
return new JsonResponse($this->annotationPage($canvas_id, $request));
}
/**
* Returns annotations for a set of canvases in one request.
*
* This is used by the "All annotations" manifest sidebar. It deliberately
* accepts canvas IDs rather than re-fetching the manifest on the server, so
* it also works with authenticated/proxied manifests already available to
* the browser.
*/
public function manifestCollection(Request $request): JsonResponse {
$data = $this->decodeRequest($request);
$canvas_ids = $data['canvasIds'] ?? NULL;
if (!is_array($canvas_ids)) {
throw new BadRequestHttpException('canvasIds must be an array.');
}
$canvas_ids = array_values(array_unique(array_filter(array_map(
static fn ($value) => is_string($value) ? trim($value) : '',
$canvas_ids
))));
// Keep an accidental or malicious request from building an enormous IN
// clause. IIIF manifests with thousands of canvases remain supported.
if (count($canvas_ids) > 10000) {
throw new BadRequestHttpException('Too many canvas IDs requested.');
}
if (!$canvas_ids) {
return new JsonResponse(['items' => []]);
}
$items = [];
foreach (array_chunk($canvas_ids, 500) as $chunk) {
$keys = array_map(static fn ($id) => hash('sha256', $id), $chunk);
$result = $this->database->select('islandora_mirador_annotation', 'a')
->fields('a', ['canvas_id', 'annotation_json', 'created'])
->condition('canvas_key', $keys, 'IN')
->orderBy('created', 'ASC')
->execute();
foreach ($result as $row) {
try {
$annotation = json_decode($row->annotation_json, TRUE, 512, JSON_THROW_ON_ERROR);
if (is_array($annotation)) {
$items[] = [
'canvasId' => $row->canvas_id,
'annotation' => $annotation,
];
}
}
catch (\JsonException) {
// Ignore a corrupt row rather than breaking the entire manifest list.
}
}
}
return new JsonResponse(['items' => $items]);
}
/**
* Creates an annotation and returns the updated AnnotationPage.
*/
@ -155,9 +215,54 @@ final class AnnotationController extends ControllerBase {
throw new BadRequestHttpException('canvasId and annotation are required.');
}
// The Web Annotation target is authoritative. MAE can occasionally save
// through an adapter instance that was created for a previously active
// canvas during rapid page changes. Store the annotation against its
// actual target canvas so Mirador can retrieve and render it correctly.
$target_canvas_id = $this->targetCanvasId($annotation);
if ($target_canvas_id !== NULL) {
$canvas_id = $target_canvas_id;
}
return [$canvas_id, $annotation];
}
/**
* Returns the canvas targeted by a Web Annotation.
*/
private function targetCanvasId(array $annotation): ?string {
$target = $annotation['target'] ?? NULL;
if (is_string($target)) {
$value = explode('#', $target, 2)[0];
return $value !== '' ? $value : NULL;
}
if (!is_array($target)) {
return NULL;
}
$source = $target['source'] ?? NULL;
if (is_string($source)) {
$value = explode('#', $source, 2)[0];
return $value !== '' ? $value : NULL;
}
if (is_array($source)) {
$value = $source['id'] ?? $source['@id'] ?? NULL;
if (is_string($value) && $value !== '') {
return explode('#', $value, 2)[0];
}
}
$value = $target['id'] ?? $target['@id'] ?? NULL;
if (is_string($value) && $value !== '') {
return explode('#', $value, 2)[0];
}
return NULL;
}
/**
* Decodes a JSON request body.
*/

Loading…
Cancel
Save