Skip to content

Commit

Permalink
Merge pull request #756 from samvera-labs/linked-annotations
Browse files Browse the repository at this point in the history
Convert data from linked annotations to the same data format
  • Loading branch information
Dananji authored Dec 17, 2024
2 parents a959b41 + 30538d0 commit 0207a97
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 38 deletions.
38 changes: 19 additions & 19 deletions src/services/annotation-parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,15 @@ describe('annotation-parser', () => {
expect(label).toEqual('Default');
});

test('returns annotations for AnnotationPage without TextualBody annotations', () => {
test('returns linked annotations for AnnotationPage without TextualBody annotations', () => {
const { canvasIndex, annotationSets } = annotationParser.parseAnnotationSets(lunchroomManners, 0);
expect(canvasIndex).toEqual(0);
expect(annotationSets.length).toEqual(1);

const { items, label } = annotationSets[0];
expect(items.length).toEqual(1);
expect(label).toEqual('');
const { format, label, url } = annotationSets[0];
expect(label).toEqual('Captions in WebVTT format');
expect(format).toEqual('text/vtt');
expect(url).toEqual('https://example.com/manifest/lunchroom_manners.vtt');
});

test('returns AnnotationPage info for AnnotationPage without items property', () => {
Expand Down Expand Up @@ -441,9 +442,9 @@ describe('annotation-parser', () => {
expect(items.length).toEqual(4);
expect(items[0].motivation).toEqual(['commenting', 'tagging']);
expect(items[0].id).toEqual('https://example.com/avannotate-test/canvas-1/canvas/page/1');
expect(items[0].times).toEqual({ start: 0, end: 39 });
expect(items[0].time).toEqual({ start: 0, end: 39 });
expect(items[0].canvasId).toEqual('https://example.com/avannotate-test/canvas-1/canvas');
expect(items[0].body).toEqual([
expect(items[0].value).toEqual([
{ format: 'text/plain', purpose: ['commenting'], value: 'Men singing' },
{ format: 'text/plain', purpose: ['tagging'], value: 'Unknown' }]);
});
Expand All @@ -469,13 +470,12 @@ describe('annotation-parser', () => {
const items = annotationParser.parseAnnotationItems(annotations, 572.34);
expect(items[0].motivation).toEqual(['supplementing']);
expect(items[0].id).toEqual('https://example.com/manifest/lunchroom_manners/canvas/1/annotation/1');
expect(items[0].times).toBeUndefined();
expect(items[0].time).toBeUndefined();
expect(items[0].canvasId).toEqual('https://example.com/manifest/lunchroom_manners/canvas/1');
expect(items[0].body).toEqual([{
expect(items[0].value).toEqual([{
format: 'text/vtt',
value: 'Captions in WebVTT format',
label: 'Captions in WebVTT format',
url: 'https://example.com/manifest/lunchroom_manners.vtt',
isExternal: true,
}]);
});

Expand All @@ -496,7 +496,7 @@ describe('annotation-parser', () => {
const items = annotationParser.parseAnnotationItems(annotations, 809.0);

expect(items.length).toEqual(1);
expect(items[0].body).toEqual([
expect(items[0].value).toEqual([
{ format: 'text/plain', purpose: ['commenting'], value: '[Inaudible]' },
{ format: 'text/plain', purpose: ['tagging'], value: 'Inaudible' }]);
});
Expand All @@ -517,7 +517,7 @@ describe('annotation-parser', () => {
const items = annotationParser.parseAnnotationItems(annotations, 809.0);

expect(items.length).toEqual(1);
expect(items[0].body).toEqual([
expect(items[0].value).toEqual([
{ format: 'text/plain', purpose: ['commenting'], value: '[Inaudible]' },
{ format: 'text/plain', purpose: ['tagging'], value: 'Inaudible' }]);
});
Expand All @@ -539,7 +539,7 @@ describe('annotation-parser', () => {
];
const items = annotationParser.parseAnnotationItems(annotations, 809.0);

expect(items[0].times).toEqual({ start: 52, end: 60 });
expect(items[0].time).toEqual({ start: 52, end: 60 });
expect(items[0].canvasId).toEqual('https://example.com/avannotate-test/canvas-1/canvas');
});

Expand Down Expand Up @@ -573,7 +573,7 @@ describe('annotation-parser', () => {
];
const items = annotationParser.parseAnnotationItems(annotations, 809.0);

expect(items[0].times).toEqual({ start: 52, end: 60 });
expect(items[0].time).toEqual({ start: 52, end: 60 });
expect(items[0].canvasId).toEqual('https://example.com/avannotate-test/canvas-1/canvas');
});

Expand Down Expand Up @@ -606,7 +606,7 @@ describe('annotation-parser', () => {
];
const items = annotationParser.parseAnnotationItems(annotations, 809.0);

expect(items[0].times).toEqual({ start: 52, end: undefined });
expect(items[0].time).toEqual({ start: 52, end: undefined });
expect(items[0].canvasId).toEqual('https://example.com/avannotate-test/canvas-1/canvas');
});
});
Expand Down Expand Up @@ -642,9 +642,9 @@ describe('annotation-parser', () => {
expect(items[0]).toEqual({
motivation: ['supplementing', 'commenting'],
id: 'default-annotation-0.json',
times: { start: 2761.474609, end: 2764.772727 },
time: { start: 2761.474609, end: 2764.772727 },
canvasId: 'http://example.com/s1576-t86-244/canvas-1/canvas',
body: [
value: [
{ format: 'text/plain', purpose: ['commenting'], value: 'Alabama Singleton. I am 33-years-old.' },
{ format: 'text/plain', purpose: ['tagging'], value: 'Default' }
]
Expand All @@ -656,9 +656,9 @@ describe('annotation-parser', () => {
expect(items[1]).toEqual({
motivation: ['supplementing', 'commenting'],
id: 'default-annotation-1.json',
times: { start: 2766.438533, end: undefined },
time: { start: 2766.438533, end: undefined },
canvasId: 'http://example.com/s1576-t86-244/canvas-1/canvas',
body: [
value: [
{ format: 'text/plain', purpose: ['commenting'], value: 'Savannah, GA' },
{ format: 'text/plain', purpose: ['tagging'], value: 'Default' }
]
Expand Down
92 changes: 84 additions & 8 deletions src/services/annotations-parser.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { getCanvasId } from "./iiif-parser";
import { parseTranscriptData } from "./transcript-parser";
import { getLabelValue, getMediaFragment, handleFetchErrors, parseTimeStrings } from "./utility-helpers";

/**
* Parse annotation sets relevant to the current Canvas in a
* given Manifest.
* If the AnnotationPage contains linked resources as annotations,
* returns information related to the linked resource.
* If the AnnotationPage contains TextualBody type annotations,
* returns information related to each text annotation.
* @param {Object} manifest
* @param {Number} canvasIndex
* @returns {Array}
*/
export function parseAnnotationSets(manifest, canvasIndex) {
let canvas = null;
let annotationSets = [];
Expand All @@ -26,6 +38,7 @@ export function parseAnnotationSets(manifest, canvasIndex) {

/**
* Fetch and parse linked AnnotationPage json file
* @function parseExternalAnnotationPage
* @param {String} url URL of the linked AnnotationPage .json
* @param {Number} duration Canvas duration
* @returns {Object} JSON object for the annotations
Expand Down Expand Up @@ -74,6 +87,7 @@ export async function parseExternalAnnotationPage(url, duration) {

/**
* Parse a annotations in a given list of AnnotationPage objects.
* @function parseAnnotationPage
* @param {Array} annotationPages AnnotationPage from either Canvas or linked .json
* @param {Number} duration Canvas duration
* @returns {Array<Object>} a parsed list of annotations in the AnnotationPage
Expand All @@ -86,19 +100,52 @@ function parseAnnotationPages(annotationPages, duration) {
if (annotation.type === 'AnnotationPage') {
let annotationSet = { label: getLabelValue(annotation.label) };
if (annotation.items?.length > 0) {
annotationSet.items = parseAnnotationItems(annotation.items, duration);
if (isExternalAnnotation(annotation.items[0]?.body)) {
annotation.items.map((item) => {
const { body, id, motivation, target } = item;
annotationSet = {
...parseAnnotationBody(body)[0],
linkedResource: true,
canvasId: target,
id: id,
motivation: Array.isArray(motivation) ? motivation : [motivation],
};
annotationSets.push(annotationSet);
});
} else {
annotationSet.items = parseAnnotationItems(annotation.items, duration);
annotationSets.push(annotationSet);
}
} else {
annotationSet.url = annotation.id;
annotationSet.format = 'application/json';
annotationSets.push(annotationSet);
}
annotationSets.push(annotationSet);
}
});
}
return annotationSets;
}

/**
* Determine whether a given Annotation has a linked resource or
* a TextualBody with text values in its 'body' property.
* @function isExternalAnnotaion
* @param {Array} annotationBody array of 'body' in Annotation
* @returns {Boolean}
*/
function isExternalAnnotation(annotationBody) {
if (!Array.isArray(annotationBody)) annotationBody = [annotationBody];

return annotationBody.map((body) => {
return body.type != 'TextualBody';
}).reduce((acc, current) => acc && current,
true);
}

/**
* Parse each Annotation in a given AnnotationPage resource
* @function parseAnnotationItems
* @param {Array} annotations list of annotations from AnnotationPage
* @param {Number} duration Canvas duration
* @returns {Array} array of JSON objects for each Annotation
Expand All @@ -107,7 +154,7 @@ function parseAnnotationPages(annotationPages, duration) {
* id: String,
* times: { start: Number, end: Number || undefined },
* canvasId: URI,
* body: [ return type of parseTextualBody() ]
* value: [ return type of parseTextualBody() ]
* }]
*/
export function parseAnnotationItems(annotations, duration) {
Expand All @@ -130,19 +177,20 @@ export function parseAnnotationItems(annotations, duration) {
motivation: Array.isArray(annotation.motivation)
? annotation.motivation : [annotation.motivation],
id: annotation.id,
times: times,
time: times,
canvasId,
body: parseAnnotationBody(annotation.body)
value: parseAnnotationBody(annotation.body)
});
});

// Sort by start time of annotations
items.sort((a, b) => a.times?.start - b.times?.start);
items.sort((a, b) => a.time?.start - b.time?.start);
return items;
};

/**
* Parse different types of temporal selectors given in an Annotation
* @function parseSelector
* @param {Object} selector Selector object from an Annotation
* @param {Number} duration Canvas duration
* @returns {Object} start, end times of an Annotation
Expand All @@ -163,6 +211,7 @@ function parseSelector(selector, duration) {

/**
* Parse value of a TextualBody into a JSON object
* @function parseTextualBody
* @param {Object} textualBody TextualBody type object
* @returns {Object} JSON object for TextualBody value
* { format: String, purpose: Array<String>, value: String }
Expand All @@ -187,6 +236,7 @@ function parseTextualBody(textualBody) {

/**
* Parse 'body' of an Annotation into a JSON object.
* @function parseAnnotationBody
* @param {Array || Object} annotationBody body property of an Annotation
*/
function parseAnnotationBody(annotationBody) {
Expand All @@ -204,12 +254,38 @@ function parseAnnotationBody(annotationBody) {
case 'Text':
values.push({
format: body.format,
value: getLabelValue(body.label),
label: getLabelValue(body.label),
url: body.id,
isExternal: true,
});
break;
}
});
return values;
}

/**
* A wrapper function around 'parseTranscriptData()' from 'transcript-parser' module.
* Converts the data from linked resources in annotations in a Manifest/Canvas
* into a format expected in the 'Annotations' component for displaying.
* Parse linked resources (WebVTT, SRT, MS Doc, etc.) in a given Annotation
* into a list of JSON objects to a format similar to annotations with
* 'TextualBody' type in an AnnotationPage.
* @function parseExternalAnnotationResource
* @param {Object} annotation Annotation for the linked resource
* @returns {Array} parsed data from a linked resource in the same format as
* the return type of parseAnnotationItems() function.
*/
export async function parseExternalAnnotationResource(annotation) {
const { canvasId, format, id, motivation, url } = annotation;
const { tData } = await parseTranscriptData(url, format);
return tData.map((data) => {
const { begin, end, text } = data;
return {
canvasId,
id,
motivation,
time: { start: begin, end },
value: [{ format: 'text/plain', purpose: motivation, value: text }],
};
});
}
2 changes: 1 addition & 1 deletion src/services/ramp-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,7 @@ export const useTranscripts = ({
} else {
// Parse new transcript data from the given sources
await Promise.resolve(
parseTranscriptData(url, canvasIndexRef.current, format)
parseTranscriptData(url, format, canvasIndexRef.current)
).then(function (value) {
if (value != null) {
const { tData, tUrl, tType, tFileExt } = value;
Expand Down
16 changes: 8 additions & 8 deletions src/services/transcript-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,11 @@ export function groupByIndex(objectArray, indexKey, selectKey) {
* doc viewer is rendered, IIIF manifest -> extract and parse transcript data
* within the manifest.
* @param {String} url URL of the transcript file selected
* @param {Number} canvasIndex Current canvas rendered in the player
* @param {String} format transcript file format read from Annotation
* @param {Number} canvasIndex Current canvas rendered in the player
* @returns {Object} Array of trancript data objects with download URL
*/
export async function parseTranscriptData(url, canvasIndex, format) {
export async function parseTranscriptData(url, format, canvasIndex) {
let tData = [];
let tUrl = url;

Expand Down Expand Up @@ -297,17 +297,17 @@ export async function parseTranscriptData(url, canvasIndex, format) {
fileType = filteredExt.length > 0 ? urlExt : '';
}

// Return empty array to display an error message
if (canvasIndex === undefined) {
return { tData, tUrl, tType: TRANSCRIPT_TYPES.noTranscript };
}

let textData, textLines;
switch (fileType) {
case 'json':
let jsonData = await fileData.json();
if (jsonData?.type === 'Manifest') {
return parseManifestTranscript(jsonData, url, canvasIndex);
// Return empty array to display an error message
if (canvasIndex === undefined) {
return { tData, tUrl, tType: TRANSCRIPT_TYPES.noTranscript };
} else {
return parseManifestTranscript(jsonData, url, canvasIndex);
}
} else {
let json = parseJSONData(jsonData);
return { tData: json.tData, tUrl, tType: json.tType, tFileExt: fileType };
Expand Down
Loading

0 comments on commit 0207a97

Please sign in to comment.