@europeana/portal
Version:
Europeana Portal
446 lines (392 loc) • 16.4 kB
JavaScript
import pick from 'lodash/pick';
import uniq from 'lodash/uniq';
import merge from 'deepmerge';
import { apiError, createAxios, reduceLangMapsForLocale, isLangMap } from './utils';
import search from './search';
import thumbnail, { thumbnailTypeForMimeType } from './thumbnail';
import { isIIIFPresentation, isIIIFImage } from '../media';
import { ITEM_URL_PREFIX as EUROPEANA_DATA_URL_ITEM_PREFIX } from './data';
export const BASE_URL = process.env.EUROPEANA_RECORD_API_URL || 'https://api.europeana.eu/record';
const MAX_VALUES_PER_PROXY_FIELD = 10;
/**
* Sorts an array of objects by the `isNextInSequence` property.
*
* Logic:
* * Any objects not having `isNextInSequence` will not be moved.
* * Any objects having `isNextInSequence` will be moved to the position
* immediately following the other object whose `about` property matches this
* one's `isNextInSequence`
*
* @param {Object[]} source items to sort
* @return {Object[]} sorted items
* @example
* const unsorted = [
* { about: 'd', isNextInSequence: 'c' },
* { about: 'b', isNextInSequence: 'a' },
* { about: 'a' },
* { about: 'c', isNextInSequence: 'b' }
* ];
* const sorted = sortByIsNextInSequence(unsorted);
* console.log(sorted[0].about); // expected output: 'a'
* console.log(sorted[1].about); // expected output: 'b'
* console.log(sorted[2].about); // expected output: 'c'
* console.log(sorted[3].about); // expected output: 'd'
*/
function sortByIsNextInSequence(source) {
// Make a copy to work on
const items = [].concat(source);
const itemUris = items.map((item) => item.about);
for (const uri of itemUris) {
// It's necessary to find the item on each iteration to sort as it may have
// been moved from its original position by a previous iteration.
const sortItemIndex = items.findIndex((item) => item.about === uri);
const sortItem = items[sortItemIndex];
// If it has isNextInSequence property, move it after that item; else
// leave it be.
if (sortItem.isNextInSequence) {
const isPreviousInSequenceIndex = items.findIndex((item) => item.about === sortItem.isNextInSequence);
if (isPreviousInSequenceIndex !== -1) {
// Remove the item from its original position.
items.splice(sortItemIndex, 1);
// Insert the item after its predecessor.
items.splice(isPreviousInSequenceIndex + 1, 0, sortItem);
}
}
}
return items;
}
function isUndefined(value) {
return value === undefined;
}
function isNotUndefined(value) {
return !isUndefined(value);
}
/**
* Update a set of fields, in order to find linked entity data.
* will match any literal values in the 'def' key to about fields
* in any of the entities and return the related object instead of
* the plain string.
* @param fields Object representing the metadata fields
* @param entities key(URI) value(JSON object) map of entity objects for this record
* @return {Object[]} The fields with any entities as JSON objects
*/
function lookupEntities(fields, entities) {
for (const key in fields) {
setMatchingEntities(fields, key, entities);
}
return fields;
}
function setMatchingEntities(fields, key, entities) {
// Only looks for entities in 'def'
const values = (fields[key]['def'] || []);
for (const [index, value] of values.entries()) {
if (entities[value]) {
fields[key]['def'][index] = entities[value];
}
}
}
const findProxy = (proxies, type) => proxies.find(proxy => proxy.about?.startsWith(`/proxy/${type}/`));
/**
* Determine if a field will be displaying data from enrichment.
* Should only be called in the context of a aggregatorProxy being present.
* If the UI language is not in the enrichment, but also not in the default proxy,
* the enrichment will be checked for an english fallback value which would take precedence.
* @param {String} field the field name to check
* @param {Object} aggregatorProxy the proxy with the enrichment data
* @param {Object} providerProxy provider proxy, used to confirm whether preferable values exist outside the enriched data
* @param {String} predictedUiLang the two letter language code which will be the prefered UI language
* @return {Boolean} true if enriched data will be shown
*/
const localeSpecificFieldValueIsFromEnrichment = (field, aggregatorProxy, providerProxy, predictedUiLang, entities) => {
if (isLangMap(aggregatorProxy[field]) &&
(proxyHasEntityForField(aggregatorProxy, field, entities) ||
proxyHasLanguageField(aggregatorProxy, field, predictedUiLang) ||
proxyHasFallbackField(providerProxy, aggregatorProxy, field, predictedUiLang)
)
) {
return true;
}
return false;
};
const proxyHasEntityForField = (proxy, field, entities) => {
if (Array.isArray(proxy?.[field]?.def)) {
return proxy?.[field]?.def.some(key => {
return entities[key];
});
}
return entities[proxy?.[field]?.def];
};
const proxyHasLanguageField = (proxy, field, targetLanguage) => {
return proxy?.[field]?.[targetLanguage];
};
const proxyHasFallbackField = (proxy, fallbackProxy, field, targetLanguage) => {
return (!proxy[field]?.[targetLanguage] && fallbackProxy[field]?.['en']);
};
export default (context = {}) => {
const $axios = createAxios({ id: 'record', baseURL: BASE_URL }, context);
const thumbnailUrl = thumbnail(context).media;
return {
$axios,
search(params, options = {}) {
return search(context)($axios, params, options);
},
/**
* Find records by their identifier
* @param {Array} europeanaIds record identifiers or URIs
* @param {Object} params additional options to include in the API search query
* @return {Array} record data as returned by the API
*/
find(europeanaIds, params = {}) {
europeanaIds = europeanaIds.map(id => id.replace(EUROPEANA_DATA_URL_ITEM_PREFIX, ''));
const query = `europeana_id:("${europeanaIds.join('" OR "')}")`;
return this.search({
query,
...params
}, { addContentTierFilter: false });
},
/**
* Parse the record data based on the data from the API response
* @param {Object} edm data from API response
* @return {Object} parsed data
*/
parseRecordDataFromApiResponse(data, options = {}) {
const edm = data.object;
const providerAggregation = edm.aggregations[0];
const concepts = (edm.concepts || []).map(reduceEntity).map(Object.freeze);
const places = (edm.places || []).map(reduceEntity).map(Object.freeze);
const agents = (edm.agents || []).map(reduceEntity).map(Object.freeze);
const timespans = (edm.timespans || []).map(reduceEntity).map(Object.freeze);
const organizations = (edm.organizations || []).map(reduceEntity).map(Object.freeze);
const entities = [].concat(concepts, places, agents, timespans, organizations)
.filter(isNotUndefined)
.reduce((memo, entity) => {
memo[entity.about] = entity;
return memo;
}, {});
const proxies = merge.all(edm.proxies);
for (const field in proxies) {
if (isLangMap(proxies[field])) {
for (const locale in proxies[field]) {
if (Array.isArray(proxies[field][locale]) && proxies[field][locale].length > MAX_VALUES_PER_PROXY_FIELD) {
proxies[field][locale] = proxies[field][locale].slice(0, MAX_VALUES_PER_PROXY_FIELD).concat('…');
}
}
}
}
let prefLang;
if (context.$features?.translatedItems) {
prefLang = options.metadataLanguage ? options.metadataLanguage : null;
}
const predictedUiLang = prefLang || options.locale;
// Europeana proxy only really needed for the translate profile
const europeanaProxy = findProxy(edm.proxies, 'europeana');
const aggregatorProxy = findProxy(edm.proxies, 'aggregator');
const providerProxy = findProxy(edm.proxies, 'provider');
for (const field in proxies) {
if (aggregatorProxy?.[field] && localeSpecificFieldValueIsFromEnrichment(field, aggregatorProxy, providerProxy, predictedUiLang, entities)) {
proxies[field].translationSource = 'enrichment';
} else if (europeanaProxy?.[field]?.[predictedUiLang] && context.$features?.translatedItems) {
proxies[field].translationSource = 'automated';
}
}
const metadata = {
...lookupEntities(
merge.all([proxies, edm.aggregations[0], edm.europeanaAggregation]), entities
),
europeanaCollectionName: edm.europeanaCollectionName ? {
url: { name: 'search', query: { query: `europeana_collectionName:"${edm.europeanaCollectionName[0]}"` } },
value: edm.europeanaCollectionName
} : null,
timestampCreated: edm.timestamp_created,
timestampUpdate: edm.timestamp_update
};
metadata.edmDataProvider = {
url: providerAggregation.edmIsShownAt, value: metadata.edmDataProvider
};
const allMediaUris = this.aggregationMediaUris(providerAggregation).map(Object.freeze);
return {
allMediaUris,
altTitle: proxies.dctermsAlternative,
description: proxies.dcDescription,
fromTranslationError: options.fromTranslationError,
identifier: edm.about,
type: edm.type, // TODO: Evaluate if this is used, if not remove.
isShownAt: providerAggregation.edmIsShownAt,
metadata: Object.freeze(metadata),
media: this.aggregationMedia(providerAggregation, allMediaUris, edm.type, edm.services),
agents,
concepts,
timespans,
organizations,
places,
title: proxies.dcTitle,
schemaOrg: data.schemaOrg ? Object.freeze(JSON.stringify(data.schemaOrg)) : undefined,
metadataLanguage: prefLang
};
},
webResourceThumbnails(webResource, aggregation, recordType) {
const type = thumbnailTypeForMimeType(webResource.ebucoreHasMimeType) || recordType;
let uri = webResource.about;
if (aggregation.edmObject && ([aggregation.edmIsShownBy, aggregation.edmIsShownAt].includes(uri))) {
uri = aggregation.edmObject;
}
return {
small: thumbnailUrl(uri, {
size: 200,
type
}),
large: thumbnailUrl(uri, {
size: 400,
type
})
};
},
aggregationMediaUris(aggregation) {
// Gather all isShownBy/At and hasView URIs
const edmIsShownByOrAt = aggregation.edmIsShownBy || aggregation.edmIsShownAt;
return uniq([edmIsShownByOrAt].concat(aggregation.hasView || []).filter(isNotUndefined));
},
aggregationMedia(aggregation, mediaUris, recordType, services = []) {
// Filter web resources to isShownBy and hasView, respecting the ordering
const media = mediaUris
.map(mediaUri => aggregation.webResources.find(webResource => mediaUri === webResource.about))
.map(reduceWebResource);
for (const webResource of media) {
// Inject thumbnail URLs
webResource.thumbnails = this.webResourceThumbnails(webResource, aggregation, recordType);
// Inject service definitions, e.g. for IIIF
webResource.services = services.filter((service) => (webResource.svcsHasService || []).includes(service.about));
// Add isShownAt to disable download for these webresources as they ar website URLs and not actual media
if (webResource.about === aggregation.edmIsShownAt) {
webResource.isShownAt = true;
}
}
// Crude check for IIIF content, which is to prevent newspapers from showing many
// IIIF viewers.
//
// Also greatly minimises response size, and hydration cost, for IIIF with
// many web resources, all of which are contained in a single manifest anyway.
let displayable;
if (isIIIFPresentation(media[0])) {
displayable = [media[0]];
} else if (media.some(isIIIFImage)) {
displayable = [media.find(isIIIFImage)];
} else {
displayable = media;
}
// Sort by isNextInSequence property if present
return sortByIsNextInSequence(displayable).map(Object.freeze);
},
/**
* Get the record data from the API
* @param {string} europeanaId ID of Europeana record
* @return {Object} parsed record data
*/
getRecord(europeanaId, options = {}) {
let path = '';
if (!this.$axios.defaults.baseURL.endsWith('/record')) {
path = '/record';
}
const params = { ...this.$axios.defaults.params };
if (context.$features?.translatedItems) {
if (options.metadataLanguage) {
params.profile = 'translate';
params.lang = options.metadataLanguage;
}
} else {
// No point in switching on experimental schema.org with item translations.
// The profiles would interfere with each other.
let schemaOrgDatasetId;
if (context.$config?.app?.schemaOrgDatasetId) {
schemaOrgDatasetId = context.$config.app.schemaOrgDatasetId;
}
if (schemaOrgDatasetId && europeanaId.startsWith(`/${schemaOrgDatasetId}/`)) {
params.profile = 'schemaOrg';
}
}
return this.$axios.get(`${path}${europeanaId}.json`, { params })
.then((response) => {
const parsed = this.parseRecordDataFromApiResponse(response.data, options);
const reduced = reduceLangMapsForLocale(parsed, parsed.metadataLanguage || options.locale, { freeze: false });
// Restore `en` prefLabel on entities, e.g. for use in EntityBestItemsSet-type sets
for (const entityType of ['agents', 'concepts', 'organizations', 'places', 'timespans']) {
for (const reducedEntity of (reduced[entityType] || [])) {
const fullEntity = parsed[entityType].find(entity => entity.about === reducedEntity.about);
if (fullEntity.prefLabel?.en !== reducedEntity.prefLabel?.en) {
reducedEntity.prefLabel.en = fullEntity.prefLabel.en;
}
}
}
return {
record: reduced,
error: null
};
})
.catch((error) => {
const errorResponse = error.response;
if (errorResponse?.status === 502 && errorResponse?.data.code === '502-TS' && !options.fromTranslationError) {
delete (options.metadataLanguage);
options.fromTranslationError = true;
return this.getRecord(europeanaId, options);
}
throw apiError(error, context);
});
},
mediaProxyUrl(mediaUrl, europeanaId, params = {}) {
if (!params['api_url']) {
// TODO: it is not ideal to hard-code "/api" here, but the media proxy
// expects Record API URLs to end thus, i.e. not /record or /api/v2
params['api_url'] = new URL(this.$axios.defaults.baseURL).origin + '/api';
}
const proxyUrl = new URL(context.$config?.europeana?.proxy?.media?.url || 'https://proxy.europeana.eu');
proxyUrl.pathname = europeanaId;
proxyUrl.searchParams.append('view', mediaUrl);
for (const name in params) {
proxyUrl.searchParams.append(name, params[name]);
}
return proxyUrl.toString();
}
};
};
const reduceEntity = (entity) => {
return pick(entity, [
'about',
'latitude',
'longitude',
'prefLabel'
]);
};
const reduceWebResource = (webResource) => {
return pick(webResource, [
'webResourceEdmRights',
'about',
'dctermsIsReferencedBy',
'ebucoreHasMimeType',
'ebucoreHeight',
'ebucoreWidth',
'edmCodecName',
'isNextInSequence',
'svcsHasService'
]);
};
/**
* Tests whether a string is a valid Europeana record ID.
* @param {string} value Value to test
* @return {Boolean}
*/
export function isEuropeanaRecordId(value) {
return /^\/\d+\/\w+$/.test(value);
}
/**
* Extracts a Record Id from a URL
* Supported formats:
* ID: /90402/SK_A_2344
* URI: http://data.europeana.eu/item/90402/SK_A_2344
* Website URL: http(s)://www.europeana.eu/($LOCALE/)item/90402/SK_A_2344
* @param {string} value URL
* @return {string}
*/
export function recordIdFromUrl(value) {
const urlMatch = value.match(/(\/\d+\/\w+)($|\?)/);
return urlMatch?.[1];
}