glam/frontend/scripts/generate-ontology-uml.cjs
kempersc b34a8ac777 Update LinkML manifest generation timestamp and enhance MappingExplorer with schema validation
- Updated the generated timestamp in the LinkML manifest file.
- Added new CSS styles for schema status and warning indicators in MappingExplorer.
- Implemented schema validation logic in MappingExplorer to check field validity against the loaded LinkML schema.
- Enhanced the UI to display schema status and warnings for invalid fields in the mapping interface.
- Refactored field details panel to show schema validity messages for target classes and slots.
- Updated various target classes and slots in custodian data mappings for consistency and accuracy.
2026-02-18 18:44:03 +01:00

356 lines
10 KiB
JavaScript

#!/usr/bin/env node
/*
* Generate a Mermaid UML class diagram from the synced LinkML schemas.
*
* Output: frontend/public/data/heritage_custodian_ontology.mmd
*
* The Visualize page loads this file from /data/heritage_custodian_ontology.mmd.
*/
const fs = require('fs');
const path = require('path');
const SCHEMA_DIR = path.join(__dirname, '../public/schemas/20251121/linkml');
const MANIFEST_PATH = path.join(SCHEMA_DIR, 'manifest.json');
const OUTPUT_PATH = path.join(__dirname, '../public/data/heritage_custodian_ontology.mmd');
function readJson(p) {
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function readText(p) {
return fs.readFileSync(p, 'utf8');
}
function ensureDir(p) {
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function safeArray(v) {
return Array.isArray(v) ? v : [];
}
function indentOf(line) {
const m = line.match(/^ */);
return m ? m[0].length : 0;
}
function isBlankOrComment(line) {
const t = line.trim();
return t === '' || t.startsWith('#');
}
function stripInlineComment(v) {
// Conservative: only strip comments when preceded by whitespace.
// This avoids clobbering URLs like http://example.com#fragment.
return v.replace(/\s+#.*$/, '').trim();
}
function stripQuotes(v) {
const s = v.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
return s;
}
function collectIndentedBlock(lines, startIndex, startIndent) {
const block = [lines[startIndex]];
for (let i = startIndex + 1; i < lines.length; i++) {
const line = lines[i];
if (isBlankOrComment(line)) {
block.push(line);
continue;
}
const ind = indentOf(line);
if (ind <= startIndent) break;
block.push(line);
}
return block;
}
function findScalarInBlock(blockLines, key, minIndent) {
// Returns a single-line scalar value for `${key}: value`.
// Ignores folded/literal multi-line scalars (>- / |).
for (const line of blockLines) {
if (isBlankOrComment(line)) continue;
if (indentOf(line) < minIndent) continue;
const m = line.match(new RegExp(`^\\s*${key}:\\s*(.+)\\s*$`));
if (!m) continue;
const raw = stripInlineComment(m[1]);
if (raw === '' || raw === '|' || raw === '>-') continue;
return stripQuotes(raw);
}
return null;
}
function findListInBlock(blockLines, key, minIndent) {
// Parses:
// key:
// - item
// - item2
const out = [];
for (let i = 0; i < blockLines.length; i++) {
const line = blockLines[i];
if (isBlankOrComment(line)) continue;
const ind = indentOf(line);
if (ind < minIndent) continue;
const keyMatch = line.match(new RegExp(`^\\s*${key}:\\s*$`));
if (!keyMatch) continue;
const keyIndent = ind;
for (let j = i + 1; j < blockLines.length; j++) {
const l = blockLines[j];
if (isBlankOrComment(l)) continue;
const li = indentOf(l);
if (li <= keyIndent) break;
const m = l.match(/^\s*-\s*(.+)\s*$/);
if (m) {
out.push(stripQuotes(stripInlineComment(m[1])));
}
}
break;
}
return out;
}
function parseSlotUsageOverrides(classBlockLines, minIndent) {
// Parses:
// slot_usage:
// slot_name:
// range: SomeClass
const overrides = new Map();
for (let i = 0; i < classBlockLines.length; i++) {
const line = classBlockLines[i];
if (isBlankOrComment(line)) continue;
const ind = indentOf(line);
if (ind < minIndent) continue;
if (!line.match(/^\s*slot_usage:\s*$/)) continue;
const usageIndent = ind;
let currentSlot = null;
let currentSlotIndent = null;
for (let j = i + 1; j < classBlockLines.length; j++) {
const l = classBlockLines[j];
if (isBlankOrComment(l)) continue;
const li = indentOf(l);
if (li <= usageIndent) break;
// Slot key line: ` slot_name:`
if (li === usageIndent + 2) {
const mKey = l.trim().match(/^([^\s:]+):\s*$/);
if (mKey) {
currentSlot = mKey[1];
currentSlotIndent = li;
continue;
}
}
if (currentSlot && currentSlotIndent !== null && li > currentSlotIndent) {
const mRange = l.match(/^\s*range:\s*(.+)\s*$/);
if (mRange) {
const range = stripQuotes(stripInlineComment(mRange[1]));
if (range) overrides.set(currentSlot, range);
}
}
}
break;
}
return overrides;
}
function extractNamedMappingBlock(text, rootKey, itemName) {
// Extract the YAML-ish block for `rootKey: { itemName: {...} }` without parsing YAML.
// This is resilient to invalid YAML elsewhere in the file.
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (isBlankOrComment(line)) continue;
const rootMatch = line.match(new RegExp(`^\\s*${rootKey}:\\s*$`));
if (!rootMatch) continue;
const rootIndent = indentOf(line);
for (let j = i + 1; j < lines.length; j++) {
const l = lines[j];
if (isBlankOrComment(l)) continue;
const li = indentOf(l);
if (li <= rootIndent) break;
if (li === rootIndent + 2 && l.trim() === `${itemName}:`) {
return {
blockLines: collectIndentedBlock(lines, j, li),
baseIndent: li,
};
}
}
}
return null;
}
function extractAllNamedMappingBlocks(text, rootKey) {
// Extract all mapping entry blocks under `rootKey:`
// Example:
// classes:
// Foo:
// ...
// Bar:
// ...
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (isBlankOrComment(line)) continue;
const rootMatch = line.match(new RegExp(`^\\s*${rootKey}:\\s*$`));
if (!rootMatch) continue;
const rootIndent = indentOf(line);
const blocks = [];
for (let j = i + 1; j < lines.length; j++) {
const l = lines[j];
if (isBlankOrComment(l)) continue;
const li = indentOf(l);
if (li <= rootIndent) break;
if (li === rootIndent + 2) {
const mKey = l.trim().match(/^([^\s:]+):\s*$/);
if (mKey) {
blocks.push({
name: mKey[1],
blockLines: collectIndentedBlock(lines, j, li),
baseIndent: li,
});
}
}
}
return blocks;
}
return [];
}
function main() {
if (!fs.existsSync(MANIFEST_PATH)) {
console.error(`[generate-ontology-uml] Missing manifest: ${MANIFEST_PATH}`);
console.error('[generate-ontology-uml] Run pnpm run generate-manifest first.');
process.exit(1);
}
const manifest = readJson(MANIFEST_PATH);
const classCategory = (manifest.categories || []).find((c) => c.name === 'class');
const slotCategory = (manifest.categories || []).find((c) => c.name === 'slot');
const classFiles = safeArray(classCategory && classCategory.files);
const slotFiles = safeArray(slotCategory && slotCategory.files);
const slotIndex = new Map();
for (const f of slotFiles) {
const fullPath = path.join(SCHEMA_DIR, f.path);
if (!fs.existsSync(fullPath)) continue;
const text = readText(fullPath);
const slotBlocks = extractAllNamedMappingBlocks(text, 'slots');
for (const b of slotBlocks) {
const range = findScalarInBlock(b.blockLines, 'range', b.baseIndent + 2);
const multivaluedRaw = findScalarInBlock(b.blockLines, 'multivalued', b.baseIndent + 2);
const multivalued = multivaluedRaw === 'true' || multivaluedRaw === 'True';
if (!slotIndex.has(b.name)) {
slotIndex.set(b.name, {
range,
multivalued,
});
}
}
}
const classIndex = new Map();
for (const f of classFiles) {
const fullPath = path.join(SCHEMA_DIR, f.path);
if (!fs.existsSync(fullPath)) continue;
const text = readText(fullPath);
const classBlocks = extractAllNamedMappingBlocks(text, 'classes');
for (const b of classBlocks) {
const baseIndent = b.baseIndent;
const parent = findScalarInBlock(b.blockLines, 'is_a', baseIndent + 2);
const slots = findListInBlock(b.blockLines, 'slots', baseIndent + 2);
const overrides = parseSlotUsageOverrides(b.blockLines, baseIndent + 2);
if (!classIndex.has(b.name)) {
classIndex.set(b.name, {
is_a: parent,
slots,
slot_usage_overrides: overrides,
});
}
}
}
const classNames = [...classIndex.keys()].sort((a, b) => a.localeCompare(b));
const lines = [];
lines.push('```mermaid');
lines.push('classDiagram');
lines.push(`%% Generated from LinkML manifest: /schemas/20251121/linkml/manifest.json`);
if (manifest.generated) {
lines.push(`%% Manifest generated: ${manifest.generated}`);
}
lines.push('direction LR');
for (const c of classNames) {
lines.push(`class ${c}`);
}
const edgeSet = new Set();
const addEdge = (a, b, kind, label) => {
const key = `${a}|${b}|${kind}|${label || ''}`;
if (edgeSet.has(key)) return;
edgeSet.add(key);
if (kind === 'inheritance') {
lines.push(`${b} <|-- ${a}`);
return;
}
if (kind === 'association') {
lines.push(`${a} --> ${b} : ${label}`);
}
};
for (const [className, classDef] of classIndex.entries()) {
const parent = classDef.is_a;
if (parent && classIndex.has(parent)) {
addEdge(className, parent, 'inheritance');
}
const slots = safeArray(classDef.slots);
const slotUsageOverrides = classDef.slot_usage_overrides instanceof Map ? classDef.slot_usage_overrides : new Map();
for (const slotName of slots) {
const slotDef = slotIndex.get(slotName) || {};
const range = slotUsageOverrides.get(slotName) || slotDef.range;
if (!range) continue;
if (classIndex.has(range)) {
addEdge(className, range, 'association', slotName);
}
}
}
lines.push('```');
lines.push('');
ensureDir(OUTPUT_PATH);
fs.writeFileSync(OUTPUT_PATH, lines.join('\n'), 'utf8');
console.log(`[generate-ontology-uml] Wrote ${OUTPUT_PATH}`);
console.log(`[generate-ontology-uml] Classes: ${classIndex.size}, Slots indexed: ${slotIndex.size}, Edges: ${edgeSet.size}`);
}
main();