From 2907c0372a1c0924042184ffbf463306c25e392d Mon Sep 17 00:00:00 2001 From: kempersc Date: Tue, 13 Jan 2026 19:14:31 +0100 Subject: [PATCH] feat: add Getty AAT support and resolve PREMIS/BIBFRAME URIs to human-readable LOC docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/ontology/OntologyTermPopup.tsx | 231 +++++++++++++++++- 1 file changed, 220 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ontology/OntologyTermPopup.tsx b/frontend/src/components/ontology/OntologyTermPopup.tsx index 505e5f5063..5d0d545825 100644 --- a/frontend/src/components/ontology/OntologyTermPopup.tsx +++ b/frontend/src/components/ontology/OntologyTermPopup.tsx @@ -68,6 +68,11 @@ const STANDARD_PREFIXES: Record = { '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 { } } +/** + * Fetch Getty AAT (Art & Architecture Thesaurus) entity information + */ +async function fetchGettyAATEntity(entityId: string): Promise { + 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 { 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 = { '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 = ({ @@ -621,18 +815,19 @@ export const OntologyTermPopup: React.FC = ({ }; }, [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 ( {compact} @@ -745,9 +940,23 @@ export const OntologyTermPopup: React.FC = ({ {/* URI */}
URI: - {renderUri(termInfo.uri)} + {renderUri(termInfo.uri, termInfo.termType)}
+ {/* External link for Getty AAT and similar external vocabularies */} + {termInfo.externalUrl && ( + + )} + {/* Description / Comment */} {(termInfo.description || termInfo.comment) && (
@@ -779,7 +988,7 @@ export const OntologyTermPopup: React.FC = ({ Subclass of:
{termInfo.subClassOf.map((uri, idx) => ( -
{renderUri(uri)}
+
{renderUri(uri, 'class')}
))}
@@ -791,7 +1000,7 @@ export const OntologyTermPopup: React.FC = ({ Domain:
{termInfo.domain.map((uri, idx) => ( -
{renderUri(uri)}
+
{renderUri(uri, 'class')}
))}
@@ -802,7 +1011,7 @@ export const OntologyTermPopup: React.FC = ({ Range:
{termInfo.range.map((uri, idx) => ( -
{renderUri(uri)}
+
{renderUri(uri, 'class')}
))}
@@ -820,7 +1029,7 @@ export const OntologyTermPopup: React.FC = ({ {termInfo.inverseOf && (
Inverse of: - {renderUri(termInfo.inverseOf)} + {renderUri(termInfo.inverseOf, 'property')}
)} @@ -830,7 +1039,7 @@ export const OntologyTermPopup: React.FC = ({ Sub-property of:
{termInfo.subPropertyOf.map((uri, idx) => ( -
{renderUri(uri)}
+
{renderUri(uri, 'property')}
))}