diff --git a/pyproject.toml b/pyproject.toml index 7848ecd..3b78975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fairscape-cli" -version = "1.2.8" +version = "1.2.9" description = "A utility for packaging objects and validating metadata for FAIRSCAPE" readme = "README.md" requires-python = ">=3.8" @@ -39,9 +39,8 @@ dependencies = [ "prettytable>=3.9.0", "jsonschema>=4.20.0", "sqids>=0.4.1", - "fairscape-models[schema-infer]>=1.2.0", + "fairscape-models[schema-tabular]>=1.2.2", "pyyaml", - "h5py", "frictionless>=5.0,<6.0", "beautifulsoup4", "pandas", @@ -101,3 +100,8 @@ test = [ shacl = [ "pyshacl>=0.27" ] +# Optional per-format schema inference/validation. +schema-hdf5 = ["fairscape-models[schema-hdf5]"] +schema-signal = ["fairscape-models[schema-signal]"] +schema-image = ["fairscape-models[schema-image]"] +schema-all = ["fairscape-models[schema-infer]"] diff --git a/src/fairscape_cli/commands/schema_commands.py b/src/fairscape_cli/commands/schema_commands.py index 867d570..45b6815 100644 --- a/src/fairscape_cli/commands/schema_commands.py +++ b/src/fairscape_cli/commands/schema_commands.py @@ -5,12 +5,11 @@ ValidationError ) -from fairscape_models.schema import Schema - from fairscape_cli.config import DEFAULT_CONTEXT, DEFAULT_SCHEMA_TYPE from fairscape_cli.models import ReadROCrateMetadata, AppendCrate from fairscape_cli.models.schema import ( + TabularSchema, infer_schema, validate_schema, load_schema, @@ -52,7 +51,7 @@ def create_tabular_schema( """Initialize a Tabular Schema. """ try: - schema_model = Schema.model_validate({ + schema_model = TabularSchema.model_validate({ "@id": guid or generate_schema_guid(name), "@context": DEFAULT_CONTEXT, "@type": DEFAULT_SCHEMA_TYPE, @@ -289,7 +288,8 @@ def validate(ctx, schema, data): def infer_schema_rocrate(ctx, name, description, guid, input_file, rocrate_path, schema_file): """Infer a schema from a file and optionally append it to an RO-Crate. - INPUT_FILE: File to infer schema from (CSV, TSV, Parquet, or HDF5) + INPUT_FILE: File to infer schema from. Supported: CSV, TSV, Parquet + (tabular); HDF5 (.h5/.hdf5); WFDB header (.hea); DICOM (.dcm). SCHEMA_FILE: Path to save the schema file """ try: @@ -322,6 +322,12 @@ def infer_schema_rocrate(ctx, name, description, guid, input_file, rocrate_path, except ValueError as e: click.echo(f"Error with file type: {str(e)}") ctx.exit(code=1) + except ImportError as e: + click.echo( + f"Missing optional dependency for this file type ({str(e)}). " + "Install with: pip install 'fairscape-models[schema-infer]'" + ) + ctx.exit(code=1) except Exception as e: click.echo(f"Error inferring schema: {str(e)}") ctx.exit(code=1) diff --git a/src/fairscape_cli/datasheet_builder/rocrate/datasheet_generator.py b/src/fairscape_cli/datasheet_builder/rocrate/datasheet_generator.py index 945de18..a5ce7b3 100644 --- a/src/fairscape_cli/datasheet_builder/rocrate/datasheet_generator.py +++ b/src/fairscape_cli/datasheet_builder/rocrate/datasheet_generator.py @@ -30,6 +30,7 @@ SUBCRATE_MAPPING_CONFIGURATION, PREVIEW_MAPPING_CONFIGURATION ) +from fairscape_models.conversion.mapping.subcrate_utils import enrich_preview_computations from fairscape_cli.utils.rocrate_helpers import get_root_entity @@ -394,6 +395,7 @@ def process_subcrates(self): ) preview = converter.convert() + enrich_preview_computations(preview, subcrate, self.global_metadata_index) preview_html = self.preview_generator.generate(preview, self.published) diff --git a/src/fairscape_cli/datasheet_builder/rocrate/section_generators.py b/src/fairscape_cli/datasheet_builder/rocrate/section_generators.py index 35609ef..74306ce 100644 --- a/src/fairscape_cli/datasheet_builder/rocrate/section_generators.py +++ b/src/fairscape_cli/datasheet_builder/rocrate/section_generators.py @@ -226,6 +226,7 @@ def _prepare_subcrate_context(self, item: SubCrateItem, published: bool) -> Dict 'computations_count': details.computations_count, 'schemas_count': details.schemas_count, 'other_count': details.other_count, + 'datasets_with_provenance_count': details.datasets_with_provenance_count, # Format and access summaries - convert to dict for template 'file_formats': dict(details.file_formats) if details.file_formats else {}, @@ -327,7 +328,8 @@ def _prepare_items(self, items: List) -> List[Dict[str, Any]]: 'content_status': item.content_status or "Not specified", 'experimentType': getattr(item, 'experimentType', None) or "N/A", 'manufacturer': getattr(item, 'manufacturer', None) or "N/A", - 'schema_properties': getattr(item, 'schema_properties', None) + 'schema_properties': getattr(item, 'schema_properties', None), + 'computation_details': getattr(item, 'computation_details', None) } prepared.append(item_context) return prepared diff --git a/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.html.j2 b/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.html.j2 index eea851b..1b27103 100644 --- a/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.html.j2 +++ b/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.html.j2 @@ -104,6 +104,94 @@ line-height: 1.3; box-sizing: border-box; } + .node-info-button { + position: absolute; + top: 0; + right: 0; + width: 22px; + height: 22px; + color: #333; + border: none; + font-size: 12px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10; + padding: 0; + margin: 0; + border-bottom-left-radius: 4px; + opacity: 0.8; + transition: opacity 0.2s ease; + } + .node-info-button:hover { + opacity: 1; + } + .node-info-popover { + position: fixed; + max-height: 70vh; + overflow-y: auto; + background: #fff; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + padding: 10px 12px; + font-size: 13px; + color: #333; + text-align: left; + z-index: 50; + box-sizing: border-box; + cursor: default; + user-select: text; + } + .node-info-popover h4 { + margin: 0 22px 8px 0; + font-size: 1.1em; + color: #000; + border-bottom: 1px solid #eee; + padding-bottom: 4px; + word-break: break-word; + } + .node-info-popover .popover-section { + margin-bottom: 8px; + padding-bottom: 8px; + } + .node-info-popover .popover-section:not(:last-child) { + border-bottom: 1px dotted #eee; + } + .node-info-popover .prop-item { + display: flex; + margin: 4px 0; + line-height: 1.4; + } + .node-info-popover .prop-key { + font-weight: bold; + min-width: 100px; + flex-shrink: 0; + margin-right: 8px; + color: #555; + } + .node-info-popover .prop-value { + word-break: break-word; + } + .node-info-popover .popover-close { + position: absolute; + top: 6px; + right: 6px; + width: 20px; + height: 20px; + border: none; + background: transparent; + font-size: 16px; + line-height: 1; + color: #888; + cursor: pointer; + padding: 0; + } + .node-info-popover .popover-close:hover { + color: #333; + } .svg-layer { position: absolute; top: 0; diff --git a/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.js b/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.js index e4603d1..36188bf 100644 --- a/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.js +++ b/src/fairscape_cli/datasheet_builder/templates/evidence_graph/evidence_graph.js @@ -530,9 +530,33 @@ const evidenceGraphData = window.__EVIDENCE_GRAPH_DATA__; } } + function formatPropertyValue(value) { + if (value === null || value === undefined) return ""; + if (Array.isArray(value)) { + return value.map(formatPropertyValue).filter(Boolean).join(", "); + } + if (typeof value === "object") { + return value["@id"] || value.name || JSON.stringify(value); + } + return String(value); + } + + // Properties from the entity's source data worth showing in the info + // popover, beyond the ones the popover displays explicitly. + function getDisplayableProperties(sourceData) { + const EXCLUDED = new Set(["@id", "@type", "@context", "name", "label", "description", "count"]); + const props = {}; + Object.entries(sourceData || {}).forEach(([key, value]) => { + if (key.startsWith("_") || EXCLUDED.has(key)) return; + const formatted = formatPropertyValue(value); + if (formatted) props[key] = formatted; + }); + return props; + } + const { createElement, useState, useCallback, useEffect, useRef, memo } = React; - const EvidenceNode = memo(({ nodeData, onClick }) => { + const EvidenceNode = memo(({ nodeData, onClick, onInfoClick }) => { const { id, type, label, displayName, description, expandable, x, y, width, height } = nodeData; const nodeColor = getNodeColor(type); // DatasetGroup (graph-condensation node) and DatasetCollection (synthetic UI grouping @@ -548,6 +572,14 @@ const evidenceGraphData = window.__EVIDENCE_GRAPH_DATA__; } }, [id, expandable, onClick]); + const handleInfoClick = useCallback((event) => { + // Keep the 'i' click from triggering node expansion. + event.stopPropagation(); + if (onInfoClick) { + onInfoClick(id, event.currentTarget.getBoundingClientRect()); + } + }, [id, onInfoClick]); + const style = { left: `${x}px`, top: `${y}px`, @@ -567,11 +599,62 @@ const evidenceGraphData = window.__EVIDENCE_GRAPH_DATA__; createElement('div', { key: 'header', className: 'node-header', style: { backgroundColor: nodeColor } }, displayType), createElement('div', { key: 'content', className: 'node-content' }, createElement('div', { style: { width: '100%', textAlign: 'center' } }, displayName) - ) + ), + createElement('button', { + key: 'info', + className: 'node-info-button', + style: { backgroundColor: nodeColor }, + onClick: handleInfoClick, + onMouseDown: (e) => e.stopPropagation(), + 'aria-label': `Details for ${displayName || label || id}` + }, 'i') ]) ); }); + // Details panel opened by a node's 'i' button — the standalone equivalent + // of the web client's Tippy tooltip on EvidenceNode. + const NodeInfoPopover = ({ node, anchorRect, onClose }) => { + const { id, type, label, displayName, description } = node; + const sourceData = node._sourceData || {}; + const otherProps = getDisplayableProperties(sourceData); + + const PANEL_WIDTH = 360; + let left = anchorRect.right + 8; + if (left + PANEL_WIDTH > window.innerWidth - 10) { + left = Math.max(10, anchorRect.left - PANEL_WIDTH - 8); + } + const top = Math.max(10, Math.min(anchorRect.top, window.innerHeight - 200)); + + const propRow = (key, value) => createElement('div', { key: key, className: 'prop-item' }, [ + createElement('span', { key: 'k', className: 'prop-key' }, `${key}:`), + createElement('span', { key: 'v', className: 'prop-value' }, value) + ]); + + const metaRows = [ + propRow('@id', id), + propRow('@type', type), + ]; + if (description) metaRows.push(propRow('description', description)); + if (type === 'DatasetCollection' && sourceData.count !== undefined) { + metaRows.push(propRow('Items', String(sourceData.count))); + } + + return createElement('div', { + className: 'node-info-popover', + style: { left: `${left}px`, top: `${top}px`, width: `${PANEL_WIDTH}px` }, + onMouseDown: (e) => e.stopPropagation(), + onClick: (e) => e.stopPropagation() + }, [ + createElement('button', { key: 'close', className: 'popover-close', onClick: onClose, 'aria-label': 'Close details' }, '×'), + createElement('h4', { key: 'title' }, label || displayName || 'Node Details'), + createElement('div', { key: 'meta', className: 'popover-section' }, metaRows), + Object.keys(otherProps).length > 0 && createElement('div', { key: 'props', className: 'popover-section' }, + Object.entries(otherProps).map(([key, value]) => propRow(key, value)) + ) + ]); + }; + const Edge = memo(({ edgeData, sourceNode, targetNode }) => { if (!sourceNode || !targetNode || sourceNode.x === undefined || targetNode.x === undefined) { return null; @@ -659,6 +742,7 @@ const evidenceGraphData = window.__EVIDENCE_GRAPH_DATA__; const [translate, setTranslate] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [startDragPos, setStartDragPos] = useState({ x: 0, y: 0 }); + const [infoPopover, setInfoPopover] = useState(null); // { nodeId, anchorRect } const rootRef = useRef(null); @@ -709,7 +793,15 @@ const evidenceGraphData = window.__EVIDENCE_GRAPH_DATA__; }, [graphData, applyLayout]); + const handleInfoClick = useCallback((nodeId, anchorRect) => { + // Toggle: clicking the same node's 'i' again closes the popover. + setInfoPopover(current => + current && current.nodeId === nodeId ? null : { nodeId, anchorRect } + ); + }, []); + const handleNodeClick = useCallback((nodeId) => { + setInfoPopover(null); const clickedNodeIndex = nodes.findIndex(n => n.id === nodeId); if (clickedNodeIndex === -1) return; @@ -775,9 +867,10 @@ const evidenceGraphData = window.__EVIDENCE_GRAPH_DATA__; const handleMouseDown = useCallback((event) => { if (event.button !== 0) return; - if (event.target.closest('.controls-container') || event.target.closest('.node-wrapper')) { + if (event.target.closest('.controls-container') || event.target.closest('.node-wrapper') || event.target.closest('.node-info-popover')) { return; } + setInfoPopover(null); setIsDragging(true); setStartDragPos({ x: event.clientX - translate.x, y: event.clientY - translate.y }); if(rootRef.current) rootRef.current.classList.add('dragging'); @@ -864,12 +957,25 @@ const evidenceGraphData = window.__EVIDENCE_GRAPH_DATA__; nodes.map(node => createElement(EvidenceNode, { key: node.id, nodeData: node, - onClick: handleNodeClick + onClick: handleNodeClick, + onInfoClick: handleInfoClick })) ) ] ) ), + (() => { + if (!infoPopover) return null; + const infoNode = nodeMap.get(infoPopover.nodeId); + if (!infoNode) return null; + return createElement(NodeInfoPopover, { + key: 'info-popover', + node: infoNode, + anchorRect: infoPopover.anchorRect, + onClose: () => setInfoPopover(null) + }); + })(), + isLoading && createElement('div', { key: 'loading', className: 'loading-overlay' }, 'Loading...'), createElement('div', {key: 'controls', className: 'controls-container'}, [ diff --git a/src/fairscape_cli/datasheet_builder/templates/preview.html b/src/fairscape_cli/datasheet_builder/templates/preview.html index f715102..a3b9bfa 100644 --- a/src/fairscape_cli/datasheet_builder/templates/preview.html +++ b/src/fairscape_cli/datasheet_builder/templates/preview.html @@ -224,10 +224,42 @@ .schema-details-container { padding: 0; } - .toggle-schema { + .toggle-schema, + .toggle-computation { cursor: pointer; color: var(--color-accent); text-decoration: underline; + white-space: nowrap; + } + .computation-meta { + margin-bottom: 10px; + } + .computation-meta div { + margin-bottom: 4px; + } + .io-heading { + font-weight: 600; + margin: 12px 0 6px; + } + .io-crate { + color: var(--color-text-muted); + font-size: 0.85em; + } + .mono-id { + font-family: monospace; + font-size: 0.85em; + word-break: break-all; + } + .computation-command { + display: block; + padding: 8px 12px; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-family: monospace; + font-size: 0.85em; + white-space: pre-wrap; + word-break: break-all; } .properties-table { margin: 0; @@ -639,6 +671,156 @@
No {{ tab_id }} found in this RO-Crate.
{% endif %} + {% endmacro %} {% macro render_computation_table(items, tab_id, is_active) %} +| Name | +Description | +Access | +Date Created | +Details | +
|---|---|---|---|---|
| {{ item.name }} | ++ {{ item.description_display }} + | +{{ item.content_status | safe }} | +{{ item.date }} | ++ {% if item.computation_details %} + Show Details + {% else %} No details found {% endif %} + | +
No {{ tab_id }} found in this RO-Crate.
+ {% endif %} +