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/',
|
'wikidata': 'http://www.wikidata.org/entity/',
|
||||||
'wdt': 'http://www.wikidata.org/prop/direct/',
|
'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
|
// Project-specific
|
||||||
'linkml': 'https://w3id.org/linkml/',
|
'linkml': 'https://w3id.org/linkml/',
|
||||||
'hc': 'https://w3id.org/heritage/custodian/',
|
'hc': 'https://w3id.org/heritage/custodian/',
|
||||||
|
|
@ -110,7 +115,7 @@ interface TermInfo {
|
||||||
description?: string;
|
description?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
ontologyName: string;
|
ontologyName: string;
|
||||||
termType: 'class' | 'property' | 'individual' | 'unknown';
|
termType: 'class' | 'property' | 'individual' | 'concept' | 'unknown';
|
||||||
// Class-specific
|
// Class-specific
|
||||||
subClassOf?: string[];
|
subClassOf?: string[];
|
||||||
equivalentClass?: string[];
|
equivalentClass?: string[];
|
||||||
|
|
@ -123,6 +128,8 @@ interface TermInfo {
|
||||||
// Wikidata-specific
|
// Wikidata-specific
|
||||||
wikidataId?: string;
|
wikidataId?: string;
|
||||||
aliases?: string[];
|
aliases?: string[];
|
||||||
|
// External URL (for Getty AAT, etc.)
|
||||||
|
externalUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -134,6 +141,17 @@ interface WikidataEntity {
|
||||||
aliases?: { en?: Array<{ value: string }> };
|
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
|
* 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
|
* Load term information from ontology files or Wikidata
|
||||||
*/
|
*/
|
||||||
|
|
@ -231,6 +350,11 @@ async function loadTermInfo(curie: string): Promise<TermInfo | null> {
|
||||||
return fetchWikidataEntity(curie);
|
return fetchWikidataEntity(curie);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Getty AAT terms
|
||||||
|
if (prefix === 'aat') {
|
||||||
|
return fetchGettyAATEntity(curie);
|
||||||
|
}
|
||||||
|
|
||||||
// Expand the CURIE to full URI and normalize Schema.org URIs
|
// Expand the CURIE to full URI and normalize Schema.org URIs
|
||||||
let fullUri = expandCurie(curie);
|
let fullUri = expandCurie(curie);
|
||||||
fullUri = normalizeSchemaOrgUri(fullUri);
|
fullUri = normalizeSchemaOrgUri(fullUri);
|
||||||
|
|
@ -373,6 +497,71 @@ function compactUri(uri: string): string {
|
||||||
return uri;
|
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
|
* Ontology badge colors based on the ontology type
|
||||||
*/
|
*/
|
||||||
|
|
@ -389,13 +578,18 @@ const ONTOLOGY_COLORS: Record<string, string> = {
|
||||||
'DCAT 3': '#3B82F6', // Blue
|
'DCAT 3': '#3B82F6', // Blue
|
||||||
'CPOV (Core Public Org)': '#3B82F6',
|
'CPOV (Core Public Org)': '#3B82F6',
|
||||||
'BIBFRAME': '#14B8A6', // Teal
|
'BIBFRAME': '#14B8A6', // Teal
|
||||||
|
'PREMIS 3': '#0891B2', // Cyan - LOC digital preservation
|
||||||
'EDM': '#0369A1', // Sky dark - Europeana blue
|
'EDM': '#0369A1', // Sky dark - Europeana blue
|
||||||
'TOOI': '#14B8A6',
|
'TOOI': '#14B8A6',
|
||||||
'PiCo': '#0EA5E9', // Sky
|
'PiCo': '#0EA5E9', // Sky
|
||||||
'Wikidata': '#339966', // Wikidata green
|
'Wikidata': '#339966', // Wikidata green
|
||||||
|
'Getty AAT': '#990033', // Getty maroon
|
||||||
|
'Getty TGN': '#990033', // Getty maroon
|
||||||
|
'Getty ULAN': '#990033', // Getty maroon
|
||||||
'TIME': '#6B7280', // Gray
|
'TIME': '#6B7280', // Gray
|
||||||
'GEO': '#059669', // Emerald
|
'GEO': '#059669', // Emerald
|
||||||
'VCard': '#7C3AED', // Violet
|
'VCard': '#7C3AED', // Violet
|
||||||
|
'LOCN': '#7C3AED', // Violet - W3C location
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
|
|
@ -621,18 +815,19 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
};
|
};
|
||||||
}, [isResizing, handleResizeMouseMove, handleResizeMouseUp, isDragging]);
|
}, [isResizing, handleResizeMouseMove, handleResizeMouseUp, isDragging]);
|
||||||
|
|
||||||
// Render URI as clickable link
|
// Render URI as clickable link with human-readable documentation URL
|
||||||
const renderUri = (uri: string) => {
|
const renderUri = (uri: string, termType?: 'class' | 'property' | 'individual' | 'concept' | 'unknown') => {
|
||||||
const compact = compactUri(uri);
|
const compact = compactUri(uri);
|
||||||
|
const humanReadableUrl = getHumanReadableUrl(uri, termType);
|
||||||
return (
|
return (
|
||||||
<span className="ontology-popup__uri">
|
<span className="ontology-popup__uri">
|
||||||
<code className="ontology-popup__uri-compact">{compact}</code>
|
<code className="ontology-popup__uri-compact">{compact}</code>
|
||||||
<a
|
<a
|
||||||
href={uri}
|
href={humanReadableUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="ontology-popup__uri-link"
|
className="ontology-popup__uri-link"
|
||||||
title={uri}
|
title={humanReadableUrl !== uri ? `${humanReadableUrl}\n(RDF: ${uri})` : uri}
|
||||||
>
|
>
|
||||||
↗
|
↗
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -745,9 +940,23 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
{/* URI */}
|
{/* URI */}
|
||||||
<div className="ontology-popup__section">
|
<div className="ontology-popup__section">
|
||||||
<span className="ontology-popup__section-label">URI:</span>
|
<span className="ontology-popup__section-label">URI:</span>
|
||||||
{renderUri(termInfo.uri)}
|
{renderUri(termInfo.uri, termInfo.termType)}
|
||||||
</div>
|
</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 */}
|
{/* Description / Comment */}
|
||||||
{(termInfo.description || termInfo.comment) && (
|
{(termInfo.description || termInfo.comment) && (
|
||||||
<div className="ontology-popup__section">
|
<div className="ontology-popup__section">
|
||||||
|
|
@ -779,7 +988,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
<span className="ontology-popup__section-label">Subclass of:</span>
|
<span className="ontology-popup__section-label">Subclass of:</span>
|
||||||
<div className="ontology-popup__uri-list">
|
<div className="ontology-popup__uri-list">
|
||||||
{termInfo.subClassOf.map((uri, idx) => (
|
{termInfo.subClassOf.map((uri, idx) => (
|
||||||
<div key={idx}>{renderUri(uri)}</div>
|
<div key={idx}>{renderUri(uri, 'class')}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -791,7 +1000,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
<span className="ontology-popup__section-label">Domain:</span>
|
<span className="ontology-popup__section-label">Domain:</span>
|
||||||
<div className="ontology-popup__uri-list">
|
<div className="ontology-popup__uri-list">
|
||||||
{termInfo.domain.map((uri, idx) => (
|
{termInfo.domain.map((uri, idx) => (
|
||||||
<div key={idx}>{renderUri(uri)}</div>
|
<div key={idx}>{renderUri(uri, 'class')}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -802,7 +1011,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
<span className="ontology-popup__section-label">Range:</span>
|
<span className="ontology-popup__section-label">Range:</span>
|
||||||
<div className="ontology-popup__uri-list">
|
<div className="ontology-popup__uri-list">
|
||||||
{termInfo.range.map((uri, idx) => (
|
{termInfo.range.map((uri, idx) => (
|
||||||
<div key={idx}>{renderUri(uri)}</div>
|
<div key={idx}>{renderUri(uri, 'class')}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -820,7 +1029,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
{termInfo.inverseOf && (
|
{termInfo.inverseOf && (
|
||||||
<div className="ontology-popup__section">
|
<div className="ontology-popup__section">
|
||||||
<span className="ontology-popup__section-label">Inverse of:</span>
|
<span className="ontology-popup__section-label">Inverse of:</span>
|
||||||
{renderUri(termInfo.inverseOf)}
|
{renderUri(termInfo.inverseOf, 'property')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -830,7 +1039,7 @@ export const OntologyTermPopup: React.FC<OntologyTermPopupProps> = ({
|
||||||
<span className="ontology-popup__section-label">Sub-property of:</span>
|
<span className="ontology-popup__section-label">Sub-property of:</span>
|
||||||
<div className="ontology-popup__uri-list">
|
<div className="ontology-popup__uri-list">
|
||||||
{termInfo.subPropertyOf.map((uri, idx) => (
|
{termInfo.subPropertyOf.map((uri, idx) => (
|
||||||
<div key={idx}>{renderUri(uri)}</div>
|
<div key={idx}>{renderUri(uri, 'property')}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue