Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]"]
14 changes: 10 additions & 4 deletions src/fairscape_cli/commands/schema_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {},
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand All @@ -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;
Expand Down Expand Up @@ -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);


Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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'}, [
Expand Down
Loading
Loading