529 lines
19 KiB
TypeScript
529 lines
19 KiB
TypeScript
/**
|
|
* LinkMLViewerPage.tsx - LinkML Schema Viewer Page
|
|
*
|
|
* Displays LinkML schema files with:
|
|
* - Sidebar listing schemas by category (main, classes, enums, slots)
|
|
* - Visual display of selected schema showing classes, slots, and enums
|
|
* - Raw YAML view toggle
|
|
* - Schema metadata and documentation
|
|
*/
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import {
|
|
loadSchema,
|
|
loadSchemaRaw,
|
|
type LinkMLSchema,
|
|
type LinkMLClass,
|
|
type LinkMLSlot,
|
|
type LinkMLEnum,
|
|
type SchemaFile,
|
|
extractClasses,
|
|
extractSlots,
|
|
extractEnums,
|
|
} from '../lib/linkml/schema-loader';
|
|
import './LinkMLViewerPage.css';
|
|
|
|
// Dynamically discover schema files from the modules directory
|
|
interface SchemaCategory {
|
|
name: string;
|
|
displayName: string;
|
|
files: SchemaFile[];
|
|
}
|
|
|
|
const LinkMLViewerPage: React.FC = () => {
|
|
const [categories, setCategories] = useState<SchemaCategory[]>([]);
|
|
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
|
|
const [schema, setSchema] = useState<LinkMLSchema | null>(null);
|
|
const [rawYaml, setRawYaml] = useState<string | null>(null);
|
|
const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['classes', 'enums', 'slots']));
|
|
|
|
// Initialize schema file list
|
|
useEffect(() => {
|
|
const initializeSchemas = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
// Fetch the directory listings
|
|
const classFiles = await fetchSchemaFiles('modules/classes', 'class');
|
|
const enumFiles = await fetchSchemaFiles('modules/enums', 'enum');
|
|
const slotFiles = await fetchSchemaFiles('modules/slots', 'slot');
|
|
|
|
const cats: SchemaCategory[] = [
|
|
{
|
|
name: 'main',
|
|
displayName: 'Main Schema',
|
|
files: [{ name: '01_custodian_name_modular', path: '01_custodian_name_modular.yaml', category: 'main' }]
|
|
},
|
|
{
|
|
name: 'class',
|
|
displayName: 'Classes',
|
|
files: classFiles
|
|
},
|
|
{
|
|
name: 'enum',
|
|
displayName: 'Enumerations',
|
|
files: enumFiles
|
|
},
|
|
{
|
|
name: 'slot',
|
|
displayName: 'Slots',
|
|
files: slotFiles
|
|
}
|
|
];
|
|
|
|
setCategories(cats);
|
|
|
|
// Select main schema by default
|
|
if (cats[0]?.files.length > 0) {
|
|
setSelectedSchema(cats[0].files[0]);
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to initialize schema list');
|
|
console.error(err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
initializeSchemas();
|
|
}, []);
|
|
|
|
// Fetch schema files from a directory by fetching the index
|
|
const fetchSchemaFiles = async (_dirPath: string, category: SchemaFile['category']): Promise<SchemaFile[]> => {
|
|
try {
|
|
// Try to fetch a manifest or list files
|
|
// For now, we'll use a hardcoded list based on what we know exists
|
|
|
|
if (category === 'class') {
|
|
const knownClasses = [
|
|
'Appellation', 'ArchiveOrganizationType', 'AuxiliaryDigitalPlatform', 'AuxiliaryPlace',
|
|
'BioCustodianType', 'CommercialOrganizationType', 'ConfidenceMeasure', 'Container',
|
|
'Country', 'Custodian', 'CustodianCollection', 'CustodianLegalStatus', 'CustodianName',
|
|
'CustodianObservation', 'CustodianPlace', 'CustodianType', 'DigitalPlatform',
|
|
'DigitalPlatformType', 'EducationProviderType', 'EncompassingBody', 'FeatureCustodianType',
|
|
'FeaturePlace', 'GalleryType', 'HeritageSocietyType', 'HolySiteType', 'Identifier',
|
|
'IntangibleHeritageGroupType', 'LibraryType', 'MuseumType', 'NGOType',
|
|
'OfficialInstitutionType', 'OrganizationalChangeEvent', 'OrganizationalStructure',
|
|
'PersonObservation', 'PersonalCollectionType', 'ReconstructionActivity',
|
|
'ResearchCenterType', 'Settlement', 'SourceDocument', 'Subregion',
|
|
'TasteSmellHeritageType', 'TimeSpan', 'UnknownType'
|
|
];
|
|
return knownClasses.map(name => ({
|
|
name,
|
|
path: `modules/classes/${name}.yaml`,
|
|
category: 'class'
|
|
}));
|
|
} else if (category === 'enum') {
|
|
const knownEnums = [
|
|
'AgentTypeEnum', 'AppellationTypeEnum', 'AuxiliaryDigitalPlatformTypeEnum',
|
|
'AuxiliaryPlaceTypeEnum', 'CustodianPrimaryTypeEnum', 'EncompassingBodyTypeEnum',
|
|
'EntityTypeEnum', 'FeatureTypeEnum', 'LegalStatusEnum', 'OrganizationalChangeEventTypeEnum',
|
|
'OrganizationalUnitTypeEnum', 'OrganizationBranchTypeEnum', 'PlaceSpecificityEnum',
|
|
'ReconstructionActivityTypeEnum', 'SourceDocumentTypeEnum', 'StaffRoleTypeEnum'
|
|
];
|
|
return knownEnums.map(name => ({
|
|
name,
|
|
path: `modules/enums/${name}.yaml`,
|
|
category: 'enum'
|
|
}));
|
|
} else if (category === 'slot') {
|
|
// Just show a sample of slots - there are 130+
|
|
const sampleSlots = [
|
|
'hc_id', 'preferred_label', 'alternative_names', 'description', 'identifiers',
|
|
'custodian_type', 'legal_status', 'place_designation', 'digital_platform',
|
|
'has_collection', 'organizational_structure', 'encompasses_body', 'created', 'modified'
|
|
];
|
|
return sampleSlots.map(name => ({
|
|
name,
|
|
path: `modules/slots/${name}.yaml`,
|
|
category: 'slot'
|
|
}));
|
|
}
|
|
|
|
return [];
|
|
} catch (err) {
|
|
console.error(`Failed to fetch files from ${_dirPath}:`, err);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Load selected schema
|
|
useEffect(() => {
|
|
if (!selectedSchema) return;
|
|
|
|
const loadSelectedSchema = async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const [schemaData, yamlContent] = await Promise.all([
|
|
loadSchema(selectedSchema.path),
|
|
loadSchemaRaw(selectedSchema.path)
|
|
]);
|
|
|
|
setSchema(schemaData);
|
|
setRawYaml(yamlContent);
|
|
} catch (err) {
|
|
setError(`Failed to load schema: ${selectedSchema.name}`);
|
|
console.error(err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadSelectedSchema();
|
|
}, [selectedSchema]);
|
|
|
|
const toggleSection = (section: string) => {
|
|
setExpandedSections(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(section)) {
|
|
next.delete(section);
|
|
} else {
|
|
next.add(section);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const renderClassDetails = (cls: LinkMLClass) => (
|
|
<div key={cls.name} className="linkml-viewer__item">
|
|
<h4 className="linkml-viewer__item-name">
|
|
{cls.name}
|
|
{cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">abstract</span>}
|
|
</h4>
|
|
{cls.class_uri && (
|
|
<div className="linkml-viewer__uri">
|
|
<span className="linkml-viewer__label">URI:</span>
|
|
<code>{cls.class_uri}</code>
|
|
</div>
|
|
)}
|
|
{cls.description && (
|
|
<div className="linkml-viewer__description linkml-viewer__markdown">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cls.description}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{cls.slots && cls.slots.length > 0 && (
|
|
<div className="linkml-viewer__slots-list">
|
|
<span className="linkml-viewer__label">Slots:</span>
|
|
<div className="linkml-viewer__tag-list">
|
|
{cls.slots.map(slot => (
|
|
<span key={slot} className="linkml-viewer__tag">{slot}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
|
|
<div className="linkml-viewer__mappings">
|
|
<span className="linkml-viewer__label">Exact Mappings:</span>
|
|
<div className="linkml-viewer__tag-list">
|
|
{cls.exact_mappings.map(mapping => (
|
|
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping">{mapping}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{cls.close_mappings && cls.close_mappings.length > 0 && (
|
|
<div className="linkml-viewer__mappings">
|
|
<span className="linkml-viewer__label">Close Mappings:</span>
|
|
<div className="linkml-viewer__tag-list">
|
|
{cls.close_mappings.map(mapping => (
|
|
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--close">{mapping}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderSlotDetails = (slot: LinkMLSlot) => (
|
|
<div key={slot.name} className="linkml-viewer__item">
|
|
<h4 className="linkml-viewer__item-name">
|
|
{slot.name}
|
|
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">required</span>}
|
|
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">multivalued</span>}
|
|
</h4>
|
|
{slot.slot_uri && (
|
|
<div className="linkml-viewer__uri">
|
|
<span className="linkml-viewer__label">URI:</span>
|
|
<code>{slot.slot_uri}</code>
|
|
</div>
|
|
)}
|
|
{slot.range && (
|
|
<div className="linkml-viewer__range">
|
|
<span className="linkml-viewer__label">Range:</span>
|
|
<code>{slot.range}</code>
|
|
</div>
|
|
)}
|
|
{slot.description && (
|
|
<div className="linkml-viewer__description linkml-viewer__markdown">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{slot.description}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{slot.pattern && (
|
|
<div className="linkml-viewer__pattern">
|
|
<span className="linkml-viewer__label">Pattern:</span>
|
|
<code>{slot.pattern}</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderEnumDetails = (enumDef: LinkMLEnum) => (
|
|
<div key={enumDef.name} className="linkml-viewer__item">
|
|
<h4 className="linkml-viewer__item-name">{enumDef.name}</h4>
|
|
{enumDef.description && (
|
|
<div className="linkml-viewer__description linkml-viewer__markdown">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{enumDef.description}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{enumDef.permissible_values && (
|
|
<div className="linkml-viewer__enum-values">
|
|
<span className="linkml-viewer__label">Permissible Values:</span>
|
|
<div className="linkml-viewer__value-list">
|
|
{Object.entries(enumDef.permissible_values).map(([value, details]) => (
|
|
<div key={value} className="linkml-viewer__value-item">
|
|
<code className="linkml-viewer__value-name">{value}</code>
|
|
{details.description && (
|
|
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{details.description}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{details.meaning && (
|
|
<span className="linkml-viewer__value-meaning">
|
|
<span className="linkml-viewer__label">meaning:</span> {details.meaning}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderVisualView = () => {
|
|
if (!schema) return null;
|
|
|
|
const classes = extractClasses(schema);
|
|
const slots = extractSlots(schema);
|
|
const enums = extractEnums(schema);
|
|
|
|
return (
|
|
<div className="linkml-viewer__visual">
|
|
{/* Schema Metadata */}
|
|
<div className="linkml-viewer__metadata">
|
|
<h2 className="linkml-viewer__schema-name">{schema.name || schema.title || 'Unnamed Schema'}</h2>
|
|
{schema.description && (
|
|
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{schema.description}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{schema.id && (
|
|
<div className="linkml-viewer__schema-id">
|
|
<span className="linkml-viewer__label">ID:</span>
|
|
<code>{schema.id}</code>
|
|
</div>
|
|
)}
|
|
{schema.version && (
|
|
<div className="linkml-viewer__schema-version">
|
|
<span className="linkml-viewer__label">Version:</span>
|
|
<code>{schema.version}</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Prefixes */}
|
|
{schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
|
|
<div className="linkml-viewer__section">
|
|
<button
|
|
className="linkml-viewer__section-header"
|
|
onClick={() => toggleSection('prefixes')}
|
|
>
|
|
<span className="linkml-viewer__section-icon">
|
|
{expandedSections.has('prefixes') ? '▼' : '▶'}
|
|
</span>
|
|
Prefixes ({Object.keys(schema.prefixes).length})
|
|
</button>
|
|
{expandedSections.has('prefixes') && (
|
|
<div className="linkml-viewer__prefix-list">
|
|
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
|
|
<div key={prefix} className="linkml-viewer__prefix-item">
|
|
<code className="linkml-viewer__prefix-name">{prefix}:</code>
|
|
<span className="linkml-viewer__prefix-uri">{uri}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Classes */}
|
|
{classes.length > 0 && (
|
|
<div className="linkml-viewer__section">
|
|
<button
|
|
className="linkml-viewer__section-header"
|
|
onClick={() => toggleSection('classes')}
|
|
>
|
|
<span className="linkml-viewer__section-icon">
|
|
{expandedSections.has('classes') ? '▼' : '▶'}
|
|
</span>
|
|
Classes ({classes.length})
|
|
</button>
|
|
{expandedSections.has('classes') && (
|
|
<div className="linkml-viewer__section-content">
|
|
{classes.map(renderClassDetails)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Slots */}
|
|
{slots.length > 0 && (
|
|
<div className="linkml-viewer__section">
|
|
<button
|
|
className="linkml-viewer__section-header"
|
|
onClick={() => toggleSection('slots')}
|
|
>
|
|
<span className="linkml-viewer__section-icon">
|
|
{expandedSections.has('slots') ? '▼' : '▶'}
|
|
</span>
|
|
Slots ({slots.length})
|
|
</button>
|
|
{expandedSections.has('slots') && (
|
|
<div className="linkml-viewer__section-content">
|
|
{slots.map(renderSlotDetails)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Enums */}
|
|
{enums.length > 0 && (
|
|
<div className="linkml-viewer__section">
|
|
<button
|
|
className="linkml-viewer__section-header"
|
|
onClick={() => toggleSection('enums')}
|
|
>
|
|
<span className="linkml-viewer__section-icon">
|
|
{expandedSections.has('enums') ? '▼' : '▶'}
|
|
</span>
|
|
Enumerations ({enums.length})
|
|
</button>
|
|
{expandedSections.has('enums') && (
|
|
<div className="linkml-viewer__section-content">
|
|
{enums.map(renderEnumDetails)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Imports */}
|
|
{schema.imports && schema.imports.length > 0 && (
|
|
<div className="linkml-viewer__section">
|
|
<button
|
|
className="linkml-viewer__section-header"
|
|
onClick={() => toggleSection('imports')}
|
|
>
|
|
<span className="linkml-viewer__section-icon">
|
|
{expandedSections.has('imports') ? '▼' : '▶'}
|
|
</span>
|
|
Imports ({schema.imports.length})
|
|
</button>
|
|
{expandedSections.has('imports') && (
|
|
<div className="linkml-viewer__import-list">
|
|
{schema.imports.map(imp => (
|
|
<div key={imp} className="linkml-viewer__import-item">
|
|
<code>{imp}</code>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderRawView = () => {
|
|
if (!rawYaml) return null;
|
|
|
|
return (
|
|
<div className="linkml-viewer__raw">
|
|
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="linkml-viewer-page">
|
|
{/* Left Sidebar - Schema Files */}
|
|
<aside className="linkml-viewer-page__sidebar">
|
|
<h2 className="linkml-viewer-page__sidebar-title">LinkML Schemas</h2>
|
|
|
|
{categories.map(category => (
|
|
<div key={category.name} className="linkml-viewer-page__category">
|
|
<h3 className="linkml-viewer-page__category-title">
|
|
{category.displayName}
|
|
<span className="linkml-viewer-page__category-count">({category.files.length})</span>
|
|
</h3>
|
|
<div className="linkml-viewer-page__file-list">
|
|
{category.files.map(file => (
|
|
<button
|
|
key={file.path}
|
|
className={`linkml-viewer-page__file-item ${
|
|
selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : ''
|
|
}`}
|
|
onClick={() => setSelectedSchema(file)}
|
|
>
|
|
{file.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<main className="linkml-viewer-page__main">
|
|
<header className="linkml-viewer-page__header">
|
|
<h1 className="linkml-viewer-page__title">
|
|
{selectedSchema ? selectedSchema.name : 'LinkML Schema Viewer'}
|
|
</h1>
|
|
<div className="linkml-viewer-page__tabs">
|
|
<button
|
|
className={`linkml-viewer-page__tab ${viewMode === 'visual' ? 'linkml-viewer-page__tab--active' : ''}`}
|
|
onClick={() => setViewMode('visual')}
|
|
>
|
|
Visual View
|
|
</button>
|
|
<button
|
|
className={`linkml-viewer-page__tab ${viewMode === 'raw' ? 'linkml-viewer-page__tab--active' : ''}`}
|
|
onClick={() => setViewMode('raw')}
|
|
>
|
|
Raw YAML
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="linkml-viewer-page__content">
|
|
{isLoading ? (
|
|
<div className="linkml-viewer-page__loading">Loading schema...</div>
|
|
) : error ? (
|
|
<div className="linkml-viewer-page__error">{error}</div>
|
|
) : viewMode === 'visual' ? (
|
|
renderVisualView()
|
|
) : (
|
|
renderRawView()
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LinkMLViewerPage;
|