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:
parent
fc63164335
commit
2907c0372a
1 changed files with 220 additions and 11 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue