feat: add Getty AAT support and resolve PREMIS/BIBFRAME URIs to human-readable LOC docs

- Add Getty AAT (Art & Architecture Thesaurus) vocabulary support
  - fetchGettyAATEntity() fetches term info from vocab.getty.edu JSON-LD API
  - Extracts English labels, scope notes, and aliases
  - Shows 'concept' term type for SKOS concepts

- Add getHumanReadableUrl() to map RDF URIs to documentation pages
  - PREMIS 3.0: http://www.loc.gov/premis/rdf/v3/X → id.loc.gov HTML docs
  - BIBFRAME: http://id.loc.gov/ontologies/bibframe/X → id.loc.gov HTML docs
  - Uses c_ prefix for classes, p_ for properties

- Add Getty vocabulary prefixes (aat:, tgn:, ulan:)
- Add ontology badge colors for PREMIS 3, LOCN, Getty AAT
This commit is contained in:
kempersc 2026-01-13 19:14:31 +01:00
parent fc63164335
commit 2907c0372a

View file

@ -68,6 +68,11 @@ const STANDARD_PREFIXES: Record<string, string> = {
'wikidata': 'http://www.wikidata.org/entity/',
'wdt': 'http://www.wikidata.org/prop/direct/',
// Getty Vocabularies
'aat': 'http://vocab.getty.edu/aat/',
'tgn': 'http://vocab.getty.edu/tgn/',
'ulan': 'http://vocab.getty.edu/ulan/',
// Project-specific
'linkml': 'https://w3id.org/linkml/',
'hc': 'https://w3id.org/heritage/custodian/',
@ -110,7 +115,7 @@ interface TermInfo {
description?: string;
comment?: string;
ontologyName: string;
termType: 'class' | 'property' | 'individual' | 'unknown';
termType: 'class' | 'property' | 'individual' | 'concept' | 'unknown';
// Class-specific
subClassOf?: string[];
equivalentClass?: string[];
@ -123,6 +128,8 @@ interface TermInfo {
// Wikidata-specific
wikidataId?: string;
aliases?: string[];
// External URL (for Getty AAT, etc.)
externalUrl?: string;
}
/**
@ -134,6 +141,17 @@ interface WikidataEntity {
aliases?: { en?: Array<{ value: string }> };
}
/**
* Getty AAT entity response structure (JSON-LD format)
*/
interface GettyAATEntity {
'skos:prefLabel'?: Array<{ '@value': string; '@language': string }> | { '@value': string; '@language': string };
'skos:altLabel'?: Array<{ '@value': string; '@language': string }>;
'skos:scopeNote'?: Array<{ '@value': string; '@language': string }> | { '@value': string; '@language': string };
'rdfs:comment'?: Array<{ '@value': string; '@language': string }> | { '@value': string; '@language': string };
'@type'?: string | string[];
}
/**
* Expand a CURIE to a full URI using standard prefixes
*/
@ -220,6 +238,107 @@ async function fetchWikidataEntity(entityId: string): Promise<TermInfo | null> {
}
}
/**
* Fetch Getty AAT (Art & Architecture Thesaurus) entity information
*/
async function fetchGettyAATEntity(entityId: string): Promise<TermInfo | null> {
try {
// Remove aat: prefix if present
const id = entityId.replace(/^aat:/, '');
const response = await fetch(
`http://vocab.getty.edu/aat/${id}.json`,
{ mode: 'cors' }
);
if (!response.ok) {
console.warn(`[OntologyTermPopup] Getty AAT fetch failed for ${id}: ${response.status}`);
// Return basic info with direct link as fallback
return {
uri: `http://vocab.getty.edu/aat/${id}`,
localName: id,
label: `AAT ${id}`,
description: 'Click to view on Getty Vocabularies',
ontologyName: 'Getty AAT',
termType: 'concept',
externalUrl: `http://vocab.getty.edu/aat/${id}`,
};
}
const data = await response.json();
// Getty returns JSON-LD, extract the main entity
const entity = data as GettyAATEntity;
// Extract English label from skos:prefLabel (handles both array and single value)
let label = `AAT ${id}`;
const prefLabels = entity['skos:prefLabel'];
if (Array.isArray(prefLabels)) {
const enLabel = prefLabels.find(l => l['@language'] === 'en');
if (enLabel) label = enLabel['@value'];
} else if (prefLabels && prefLabels['@value']) {
label = prefLabels['@value'];
}
// Extract English description from skos:scopeNote or rdfs:comment
let description: string | undefined;
const scopeNotes = entity['skos:scopeNote'];
if (Array.isArray(scopeNotes)) {
const enNote = scopeNotes.find(n => n['@language'] === 'en');
if (enNote) description = enNote['@value'];
} else if (scopeNotes && scopeNotes['@value']) {
description = scopeNotes['@value'];
}
if (!description) {
const comments = entity['rdfs:comment'];
if (Array.isArray(comments)) {
const enComment = comments.find(c => c['@language'] === 'en');
if (enComment) description = enComment['@value'];
} else if (comments && comments['@value']) {
description = comments['@value'];
}
}
// Extract aliases from skos:altLabel
const aliases: string[] = [];
if (entity['skos:altLabel']) {
const altLabels = Array.isArray(entity['skos:altLabel'])
? entity['skos:altLabel']
: [entity['skos:altLabel']];
altLabels.forEach(alt => {
if (alt['@language'] === 'en' && alt['@value']) {
aliases.push(alt['@value']);
}
});
}
return {
uri: `http://vocab.getty.edu/aat/${id}`,
localName: id,
label,
description,
ontologyName: 'Getty AAT',
termType: 'concept',
aliases: aliases.length > 0 ? aliases : undefined,
externalUrl: `http://vocab.getty.edu/aat/${id}`,
};
} catch (error) {
console.error('[OntologyTermPopup] Error fetching Getty AAT entity:', error);
// Return basic info with link even on error
const id = entityId.replace(/^aat:/, '');
return {
uri: `http://vocab.getty.edu/aat/${id}`,
localName: id,
label: `AAT ${id}`,
description: 'Click to view on Getty Vocabularies',
ontologyName: 'Getty AAT',
termType: 'concept',
externalUrl: `http://vocab.getty.edu/aat/${id}`,
};
}
}
/**
* Load term information from ontology files or Wikidata
*/
@ -231,6 +350,11 @@ async function loadTermInfo(curie: string): Promise<TermInfo | null> {
return fetchWikidataEntity(curie);
}
// Handle Getty AAT terms
if (prefix === 'aat') {
return fetchGettyAATEntity(curie);
}
// Expand the CURIE to full URI and normalize Schema.org URIs
let fullUri = expandCurie(curie);
fullUri = normalizeSchemaOrgUri(fullUri);
@ -373,6 +497,71 @@ function compactUri(uri: string): string {
return uri;
}
/**
* Generate human-readable documentation URL for ontology terms.
*
* Many ontologies have separate RDF namespace URIs and HTML documentation URLs.
* This function maps from the RDF URI to the human-friendly documentation page.
*
* Supported ontologies:
* - PREMIS 3.0: http://www.loc.gov/premis/rdf/v3/X → https://id.loc.gov/ontologies/premis-3-0-0.html#c_X (classes) or #p_X (properties)
* - BIBFRAME: http://id.loc.gov/ontologies/bibframe/X → https://id.loc.gov/ontologies/bibframe.html#c_X or #p_X
* - Schema.org: https://schema.org/X → https://schema.org/X (same URL works)
* - CIDOC-CRM: http://www.cidoc-crm.org/cidoc-crm/X → https://cidoc-crm.org/Entity/X/Version-X.X.X
*
* @param uri The RDF namespace URI
* @param termType Optional term type hint ('class' | 'property') for anchor generation
* @returns Human-readable documentation URL, or the original URI if no mapping exists
*/
function getHumanReadableUrl(uri: string, termType?: 'class' | 'property' | 'individual' | 'concept' | 'unknown'): string {
// PREMIS 3.0 Ontology
// RDF: http://www.loc.gov/premis/rdf/v3/RightsStatus
// HTML: https://id.loc.gov/ontologies/premis-3-0-0.html#c_RightsStatus
const premisMatch = uri.match(/^http:\/\/www\.loc\.gov\/premis\/rdf\/v3\/(.+)$/);
if (premisMatch) {
const localName = premisMatch[1];
// Use c_ for classes, p_ for properties
// If termType not provided, guess based on case: UpperCase = class, lowerCase = property
const prefix = termType === 'property' ? 'p_' :
termType === 'class' ? 'c_' :
localName[0] === localName[0].toUpperCase() ? 'c_' : 'p_';
return `https://id.loc.gov/ontologies/premis-3-0-0.html#${prefix}${localName}`;
}
// BIBFRAME Ontology
// RDF: http://id.loc.gov/ontologies/bibframe/Work
// HTML: https://id.loc.gov/ontologies/bibframe.html#c_Work
const bibframeMatch = uri.match(/^http:\/\/id\.loc\.gov\/ontologies\/bibframe\/(.+)$/);
if (bibframeMatch) {
const localName = bibframeMatch[1];
const prefix = termType === 'property' ? 'p_' :
termType === 'class' ? 'c_' :
localName[0] === localName[0].toUpperCase() ? 'c_' : 'p_';
return `https://id.loc.gov/ontologies/bibframe.html#${prefix}${localName}`;
}
// CIDOC-CRM (direct link to class page)
// RDF: http://www.cidoc-crm.org/cidoc-crm/E27_Site
// HTML: https://cidoc-crm.org/Entity/E27-Site/version-7.1.3 (complex, just use search)
// For now, link to the main CIDOC-CRM website with the entity name
const cidocMatch = uri.match(/^http:\/\/www\.cidoc-crm\.org\/cidoc-crm\/(.+)$/);
if (cidocMatch) {
// CIDOC-CRM's HTML pages are complex, so link to their entity search
// They don't have simple anchors, so we'll use the RDF URI which redirects
return uri;
}
// RiC-O (Records in Contexts Ontology)
// RDF: https://www.ica.org/standards/RiC/ontology#Rule
// HTML: https://www.ica.org/standards/RiC/ontology (no anchors, but URI works as-is)
// Schema.org - URI works as-is (content negotiation)
// https://schema.org/name → works for both RDF and HTML
// Default: return original URI
return uri;
}
/**
* Ontology badge colors based on the ontology type
*/
@ -389,13 +578,18 @@ const ONTOLOGY_COLORS: Record<string, string> = {
'DCAT 3': '#3B82F6', // Blue
'CPOV (Core Public Org)': '#3B82F6',
'BIBFRAME': '#14B8A6', // Teal
'PREMIS 3': '#0891B2', // Cyan - LOC digital preservation
'EDM': '#0369A1', // Sky dark - Europeana blue
'TOOI': '#14B8A6',
'PiCo': '#0EA5E9', // Sky
'Wikidata': '#339966', // Wikidata green
'Getty AAT': '#990033', // Getty maroon
'Getty TGN': '#990033', // Getty maroon
'Getty ULAN': '#990033', // Getty maroon
'TIME': '#6B7280', // Gray
'GEO': '#059669', // Emerald
'VCard': '#7C3AED', // Violet
'LOCN': '#7C3AED', // Violet - W3C location
};
export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
@ -621,18 +815,19 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
};
}, [isResizing, handleResizeMouseMove, handleResizeMouseUp, isDragging]);
// Render URI as clickable link
const renderUri = (uri: string) => {
// Render URI as clickable link with human-readable documentation URL
const renderUri = (uri: string, termType?: 'class' | 'property' | 'individual' | 'concept' | 'unknown') => {
const compact = compactUri(uri);
const humanReadableUrl = getHumanReadableUrl(uri, termType);
return (
<span className="ontology-popup__uri">
<code className="ontology-popup__uri-compact">{compact}</code>
<a
href={uri}
href={humanReadableUrl}
target="_blank"
rel="noopener noreferrer"
className="ontology-popup__uri-link"
title={uri}
title={humanReadableUrl !== uri ? `${humanReadableUrl}\n(RDF: ${uri})` : uri}
>
</a>
@ -745,9 +940,23 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
{/* URI */}
<div className="ontology-popup__section">
<span className="ontology-popup__section-label">URI:</span>
{renderUri(termInfo.uri)}
{renderUri(termInfo.uri, termInfo.termType)}
</div>
{/* External link for Getty AAT and similar external vocabularies */}
{termInfo.externalUrl && (
<div className="ontology-popup__section">
<a
href={termInfo.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="ontology-popup__external-link"
>
View on {termInfo.ontologyName}
</a>
</div>
)}
{/* Description / Comment */}
{(termInfo.description || termInfo.comment) && (
<div className="ontology-popup__section">
@ -779,7 +988,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
<span className="ontology-popup__section-label">Subclass of:</span>
<div className="ontology-popup__uri-list">
{termInfo.subClassOf.map((uri, idx) => (
<div key={idx}>{renderUri(uri)}</div>
<div key={idx}>{renderUri(uri, 'class')}</div>
))}
</div>
</div>
@ -791,7 +1000,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
<span className="ontology-popup__section-label">Domain:</span>
<div className="ontology-popup__uri-list">
{termInfo.domain.map((uri, idx) => (
<div key={idx}>{renderUri(uri)}</div>
<div key={idx}>{renderUri(uri, 'class')}</div>
))}
</div>
</div>
@ -802,7 +1011,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
<span className="ontology-popup__section-label">Range:</span>
<div className="ontology-popup__uri-list">
{termInfo.range.map((uri, idx) => (
<div key={idx}>{renderUri(uri)}</div>
<div key={idx}>{renderUri(uri, 'class')}</div>
))}
</div>
</div>
@ -820,7 +1029,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
{termInfo.inverseOf && (
<div className="ontology-popup__section">
<span className="ontology-popup__section-label">Inverse of:</span>
{renderUri(termInfo.inverseOf)}
{renderUri(termInfo.inverseOf, 'property')}
</div>
)}
@ -830,7 +1039,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
<span className="ontology-popup__section-label">Sub-property of:</span>
<div className="ontology-popup__uri-list">
{termInfo.subPropertyOf.map((uri, idx) => (
<div key={idx}>{renderUri(uri)}</div>
<div key={idx}>{renderUri(uri, 'property')}</div>
))}
</div>
</div>