From 9b8b93c59cec8e575cc5ab153089fc4c8b8f592f Mon Sep 17 00:00:00 2001 From: Adam Patch Date: Tue, 10 Feb 2026 11:44:31 -0500 Subject: [PATCH 01/20] chore(gitignore): ignore backups --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4e0731e..61d705d 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ sqlite:/tmp dev/code-imports/nc3rsEDA/ !dev/code-imports/nc3rsEDA/README.md /logs/ + +# Backups are for local work, not the repository +backups/ From 80e3e0a9ce66d8fc742c572893bbff9998cceecb Mon Sep 17 00:00:00 2001 From: Adam Patch Date: Wed, 18 Feb 2026 09:52:28 -0500 Subject: [PATCH 02/20] feat: Add cross-database instance transfer with relationship preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ability to pull instances from read-only source databases and transfer them to the primary database while preserving relationships. ## Changes ### Database Migration (v14) - Add `neo4j_source_profile` column to `label_definitions` table - Tracks which Neo4j connection profile a label schema was pulled from ### Service Layer (label_service.py) - Update `pull_from_neo4j()` to accept and store source_profile_name parameter - Update `get_label_instances()` to use source profile connection when available - Update `get_label_instance_count()` to use source profile connection when available - Add `transfer_to_primary()` method with: - Batch processing for memory efficiency (configurable batch size) - Relationship preservation between transferred nodes - Smart matching using first required property or 'id' field - MERGE operations to avoid duplicates ### API Layer (api_labels.py) - Update `/api/labels/pull` endpoint to pass source_profile_name to service - Update `/api/labels//instances` to return source_profile in response - Update `/api/labels//instance-count` to return source_profile in response - Add `/api/labels//transfer-to-primary` endpoint with batch_size parameter ### UI Layer (labels.html) - Add source profile badge display (πŸ”— icon) on labels list - Update "Pull Instances" button text to show source (e.g., "Pull from Read-Only Source") - Add "Transfer to Primary" button (visible only for labels with source profile) - Add transfer modal with: - Clear explanation of transfer process - Configurable batch size input - Progress indicator - Success/error reporting with statistics - Update pagination to show total count (e.g., "Page 1 of 2 (86 total instances, showing 50)") - Update instance count display to show source (e.g., "86 instances in Read-Only Source") ### Tests - Add comprehensive test suite (test_cross_database_transfer.py) with 15 tests covering: - Source profile tracking on labels - Source-aware instance pulling - Source-aware instance counting - Transfer to primary functionality - API endpoint behavior ## Fixes - Fix relative import errors by using absolute imports for scidk.core.settings ## Benefits - Enables working with instances from read-only databases - Preserves graph structure during transfer - Memory-efficient batch processing - Clear UI feedback and progress tracking πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scidk/core/migrations.py | 37 +++ scidk/services/label_service.py | 433 ++++++++++++++++++++++---- scidk/ui/templates/labels.html | 315 ++++++++++++++++++- scidk/web/routes/api_labels.py | 158 +++++++++- tests/test_cross_database_transfer.py | 427 +++++++++++++++++++++++++ 5 files changed, 1301 insertions(+), 69 deletions(-) create mode 100644 tests/test_cross_database_transfer.py diff --git a/scidk/core/migrations.py b/scidk/core/migrations.py index 4d07385..8f69996 100644 --- a/scidk/core/migrations.py +++ b/scidk/core/migrations.py @@ -463,6 +463,43 @@ def migrate(conn: Optional[sqlite3.Connection] = None) -> int: _set_version(conn, 12) version = 12 + # v13: Add graphrag_feedback table for query feedback collection + if version < 13: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS graphrag_feedback ( + id TEXT PRIMARY KEY, + session_id TEXT, + message_id TEXT, + query TEXT NOT NULL, + entities_extracted TEXT NOT NULL, + cypher_generated TEXT, + feedback TEXT NOT NULL, + timestamp REAL NOT NULL, + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE SET NULL, + FOREIGN KEY (message_id) REFERENCES chat_messages(id) ON DELETE SET NULL + ); + """ + ) + cur.execute("CREATE INDEX IF NOT EXISTS idx_graphrag_feedback_session ON graphrag_feedback(session_id);") + cur.execute("CREATE INDEX IF NOT EXISTS idx_graphrag_feedback_timestamp ON graphrag_feedback(timestamp DESC);") + + conn.commit() + _set_version(conn, 13) + version = 13 + + # v14: Add neo4j_source_profile to label_definitions for cross-database instance operations + if version < 14: + try: + cur.execute("ALTER TABLE label_definitions ADD COLUMN neo4j_source_profile TEXT") + except sqlite3.OperationalError: + # Column may already exist + pass + + conn.commit() + _set_version(conn, 14) + version = 14 + return version finally: if own: diff --git a/scidk/services/label_service.py b/scidk/services/label_service.py index b9727ee..d262859 100644 --- a/scidk/services/label_service.py +++ b/scidk/services/label_service.py @@ -38,7 +38,7 @@ def list_labels(self) -> List[Dict[str, Any]]: cursor.execute( """ SELECT name, properties, relationships, created_at, updated_at, - source_type, source_id, sync_config + source_type, source_id, sync_config, neo4j_source_profile FROM label_definitions ORDER BY name """ @@ -47,7 +47,7 @@ def list_labels(self) -> List[Dict[str, Any]]: labels = [] for row in rows: - name, props_json, rels_json, created_at, updated_at, source_type, source_id, sync_config_json = row + name, props_json, rels_json, created_at, updated_at, source_type, source_id, sync_config_json, neo4j_source_profile = row labels.append({ 'name': name, 'properties': json.loads(props_json) if props_json else [], @@ -56,7 +56,8 @@ def list_labels(self) -> List[Dict[str, Any]]: 'updated_at': updated_at, 'source_type': source_type or 'manual', 'source_id': source_id, - 'sync_config': json.loads(sync_config_json) if sync_config_json else {} + 'sync_config': json.loads(sync_config_json) if sync_config_json else {}, + 'neo4j_source_profile': neo4j_source_profile }) return labels finally: @@ -78,7 +79,7 @@ def get_label(self, name: str) -> Optional[Dict[str, Any]]: cursor.execute( """ SELECT name, properties, relationships, created_at, updated_at, - source_type, source_id, sync_config + source_type, source_id, sync_config, neo4j_source_profile FROM label_definitions WHERE name = ? """, @@ -89,19 +90,19 @@ def get_label(self, name: str) -> Optional[Dict[str, Any]]: if not row: return None - name, props_json, rels_json, created_at, updated_at, source_type, source_id, sync_config_json = row + name, props_json, rels_json, created_at, updated_at, source_type, source_id, sync_config_json, neo4j_source_profile = row # Get outgoing relationships (defined on this label) relationships = json.loads(rels_json) if rels_json else [] - # Find incoming relationships (from other labels to this label) + # Find incoming relationships (from all labels to this label) + # Include self-referential relationships (e.g., Sample -> Sample) cursor.execute( """ SELECT name, relationships FROM label_definitions - WHERE name != ? """, - (name,) + () ) incoming_relationships = [] @@ -109,6 +110,7 @@ def get_label(self, name: str) -> Optional[Dict[str, Any]]: if other_rels_json: other_rels = json.loads(other_rels_json) for rel in other_rels: + # Include if target is this label (including self-referential) if rel.get('target_label') == name: incoming_relationships.append({ 'type': rel['type'], @@ -125,7 +127,8 @@ def get_label(self, name: str) -> Optional[Dict[str, Any]]: 'updated_at': updated_at, 'source_type': source_type or 'manual', 'source_id': source_id, - 'sync_config': json.loads(sync_config_json) if sync_config_json else {} + 'sync_config': json.loads(sync_config_json) if sync_config_json else {}, + 'neo4j_source_profile': neo4j_source_profile } finally: conn.close() @@ -150,6 +153,7 @@ def save_label(self, definition: Dict[str, Any]) -> Dict[str, Any]: source_type = definition.get('source_type', 'manual') source_id = definition.get('source_id') sync_config = definition.get('sync_config', {}) + neo4j_source_profile = definition.get('neo4j_source_profile') # Validate property structure for prop in properties: @@ -178,10 +182,10 @@ def save_label(self, definition: Dict[str, Any]) -> Dict[str, Any]: """ UPDATE label_definitions SET properties = ?, relationships = ?, source_type = ?, source_id = ?, - sync_config = ?, updated_at = ? + sync_config = ?, neo4j_source_profile = ?, updated_at = ? WHERE name = ? """, - (props_json, rels_json, source_type, source_id, sync_config_json, now, name) + (props_json, rels_json, source_type, source_id, sync_config_json, neo4j_source_profile, now, name) ) created_at = existing['created_at'] else: @@ -189,10 +193,10 @@ def save_label(self, definition: Dict[str, Any]) -> Dict[str, Any]: cursor.execute( """ INSERT INTO label_definitions (name, properties, relationships, source_type, - source_id, sync_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + source_id, sync_config, neo4j_source_profile, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (name, props_json, rels_json, source_type, source_id, sync_config_json, now, now) + (name, props_json, rels_json, source_type, source_id, sync_config_json, neo4j_source_profile, now, now) ) created_at = now @@ -290,6 +294,8 @@ def pull_label_properties_from_neo4j(self, name: str) -> Dict[str, Any]: """ Pull properties and relationships for a specific label from Neo4j and merge with existing definition. + Uses the 'labels_source' role connection if configured, otherwise falls back to 'primary'. + Args: name: Label name @@ -302,7 +308,8 @@ def pull_label_properties_from_neo4j(self, name: str) -> Dict[str, Any]: try: from .neo4j_client import get_neo4j_client - neo4j_client = get_neo4j_client() + # Try labels_source role first, falls back to primary automatically + neo4j_client = get_neo4j_client(role='labels_source') if not neo4j_client: raise Exception("Neo4j client not configured") @@ -409,16 +416,24 @@ def pull_label_properties_from_neo4j(self, name: str) -> Dict[str, Any]: 'error': str(e) } - def pull_from_neo4j(self) -> Dict[str, Any]: + def pull_from_neo4j(self, neo4j_client=None, source_profile_name=None) -> Dict[str, Any]: """ Pull label schema (properties and relationships) from Neo4j and import as label definitions. + Args: + neo4j_client: Optional Neo4jClient instance to use. If not provided, uses the 'labels_source' + role connection if configured, otherwise falls back to 'primary'. + source_profile_name: Optional name of the Neo4j profile being pulled from. Will be stored + in label metadata for source-aware instance operations. + Returns: Dict with status and imported labels """ try: - from .neo4j_client import get_neo4j_client - neo4j_client = get_neo4j_client() + if neo4j_client is None: + from .neo4j_client import get_neo4j_client + # Try labels_source role first, falls back to primary automatically + neo4j_client = get_neo4j_client(role='labels_source') if not neo4j_client: raise Exception("Neo4j client not configured") @@ -511,11 +526,15 @@ def pull_from_neo4j(self) -> Dict[str, Any]: imported = [] for label_name, schema in labels_map.items(): try: - self.save_label({ + label_def = { 'name': label_name, 'properties': schema['properties'], 'relationships': schema['relationships'] - }) + } + # Store source profile if provided + if source_profile_name: + label_def['neo4j_source_profile'] = source_profile_name + self.save_label(label_def) imported.append(label_name) except Exception as e: # Continue with other labels @@ -583,6 +602,9 @@ def get_label_instances(self, name: str, limit: int = 100, offset: int = 0) -> D """ Get instances of a label from Neo4j. + If the label has a source profile configured, instances will be pulled from that profile's + connection. Otherwise, uses the default (primary) connection. + Args: name: Label name limit: Maximum number of instances to return @@ -597,40 +619,78 @@ def get_label_instances(self, name: str, limit: int = 100, offset: int = 0) -> D try: from .neo4j_client import get_neo4j_client - neo4j_client = get_neo4j_client() + + # Check if label has a source profile - if so, use that connection + source_profile = label_def.get('neo4j_source_profile') + neo4j_client = None + created_client = False + + if source_profile: + # Load and use the source profile connection + from scidk.core.settings import get_setting + import json + + profile_key = f'neo4j_profile_{source_profile.replace(" ", "_")}' + profile_json = get_setting(profile_key) + + if profile_json: + profile = json.loads(profile_json) + password_key = f'neo4j_profile_password_{source_profile.replace(" ", "_")}' + password = get_setting(password_key) + + from .neo4j_client import Neo4jClient + neo4j_client = Neo4jClient( + uri=profile.get('uri'), + user=profile.get('user'), + password=password, + database=profile.get('database', 'neo4j'), + auth_mode='basic' + ) + neo4j_client.connect() + created_client = True + + # Fall back to default connection if no source profile or profile not found + if not neo4j_client: + neo4j_client = get_neo4j_client() if not neo4j_client: raise Exception("Neo4j client not configured") - # Query for instances of this label - query = f""" - MATCH (n:{name}) - RETURN elementId(n) as id, properties(n) as properties - SKIP $offset - LIMIT $limit - """ + try: + # Query for instances of this label + query = f""" + MATCH (n:{name}) + RETURN elementId(n) as id, properties(n) as properties + SKIP $offset + LIMIT $limit + """ - results = neo4j_client.execute_read(query, {'offset': offset, 'limit': limit}) + results = neo4j_client.execute_read(query, {'offset': offset, 'limit': limit}) - instances = [] - for r in results: - instances.append({ - 'id': r.get('id'), - 'properties': r.get('properties', {}) - }) + instances = [] + for r in results: + instances.append({ + 'id': r.get('id'), + 'properties': r.get('properties', {}) + }) - # Get total count - count_query = f"MATCH (n:{name}) RETURN count(n) as total" - count_results = neo4j_client.execute_read(count_query) - total = count_results[0].get('total', 0) if count_results else 0 + # Get total count + count_query = f"MATCH (n:{name}) RETURN count(n) as total" + count_results = neo4j_client.execute_read(count_query) + total = count_results[0].get('total', 0) if count_results else 0 - return { - 'status': 'success', - 'instances': instances, - 'total': total, - 'limit': limit, - 'offset': offset - } + return { + 'status': 'success', + 'instances': instances, + 'total': total, + 'limit': limit, + 'offset': offset, + 'source_profile': source_profile # Include source info + } + finally: + # Clean up temporary client if we created one + if created_client and neo4j_client: + neo4j_client.close() except Exception as e: return { 'status': 'error', @@ -641,6 +701,9 @@ def get_label_instance_count(self, name: str) -> Dict[str, Any]: """ Get count of instances for a label from Neo4j. + If the label has a source profile configured, count will be from that profile's + connection. Otherwise, uses the default (primary) connection. + Args: name: Label name @@ -653,20 +716,58 @@ def get_label_instance_count(self, name: str) -> Dict[str, Any]: try: from .neo4j_client import get_neo4j_client - neo4j_client = get_neo4j_client() + + # Check if label has a source profile - if so, use that connection + source_profile = label_def.get('neo4j_source_profile') + neo4j_client = None + created_client = False + + if source_profile: + # Load and use the source profile connection + from scidk.core.settings import get_setting + import json + + profile_key = f'neo4j_profile_{source_profile.replace(" ", "_")}' + profile_json = get_setting(profile_key) + + if profile_json: + profile = json.loads(profile_json) + password_key = f'neo4j_profile_password_{source_profile.replace(" ", "_")}' + password = get_setting(password_key) + + from .neo4j_client import Neo4jClient + neo4j_client = Neo4jClient( + uri=profile.get('uri'), + user=profile.get('user'), + password=password, + database=profile.get('database', 'neo4j'), + auth_mode='basic' + ) + neo4j_client.connect() + created_client = True + + # Fall back to default connection if no source profile + if not neo4j_client: + neo4j_client = get_neo4j_client() if not neo4j_client: raise Exception("Neo4j client not configured") - # Query for count - query = f"MATCH (n:{name}) RETURN count(n) as count" - results = neo4j_client.execute_read(query) - count = results[0].get('count', 0) if results else 0 + try: + # Query for count + query = f"MATCH (n:{name}) RETURN count(n) as count" + results = neo4j_client.execute_read(query) + count = results[0].get('count', 0) if results else 0 - return { - 'status': 'success', - 'count': count - } + return { + 'status': 'success', + 'count': count, + 'source_profile': source_profile # Include source info + } + finally: + # Clean up temporary client if we created one + if created_client and neo4j_client: + neo4j_client.close() except Exception as e: return { 'status': 'error', @@ -697,7 +798,39 @@ def update_label_instance(self, name: str, instance_id: str, property_name: str, try: from .neo4j_client import get_neo4j_client - neo4j_client = get_neo4j_client() + + # Check if label has a source profile - if so, use that connection + source_profile = label_def.get('neo4j_source_profile') + neo4j_client = None + created_client = False + + if source_profile: + # Load and use the source profile connection + from scidk.core.settings import get_setting + import json + + profile_key = f'neo4j_profile_{source_profile.replace(" ", "_")}' + profile_json = get_setting(profile_key) + + if profile_json: + profile = json.loads(profile_json) + password_key = f'neo4j_profile_password_{source_profile.replace(" ", "_")}' + password = get_setting(password_key) + + from .neo4j_client import Neo4jClient + neo4j_client = Neo4jClient( + uri=profile.get('uri'), + user=profile.get('user'), + password=password, + database=profile.get('database', 'neo4j'), + auth_mode='basic' + ) + neo4j_client.connect() + created_client = True + + # Fall back to default connection if no source profile + if not neo4j_client: + neo4j_client = get_neo4j_client() if not neo4j_client: raise Exception("Neo4j client not configured") @@ -788,3 +921,191 @@ def overwrite_label_instance(self, name: str, instance_id: str, properties: Dict 'status': 'error', 'error': str(e) } + + def transfer_to_primary(self, name: str, batch_size: int = 100) -> Dict[str, Any]: + """ + Transfer instances of a label from its source database to the primary database. + Preserves relationships between transferred nodes. + + This operation: + 1. Pulls instances in batches from the source database + 2. Creates nodes with matching properties in the primary database + 3. Reconstructs relationships between transferred nodes + 4. Uses a matching key (first required property or 'id') to link source/target nodes + + Args: + name: Label name to transfer + batch_size: Number of instances to process per batch (default 100) + + Returns: + Dict with status, counts, and any errors + """ + label_def = self.get_label(name) + if not label_def: + raise ValueError(f"Label '{name}' not found") + + source_profile = label_def.get('neo4j_source_profile') + if not source_profile: + return { + 'status': 'error', + 'error': f"Label '{name}' has no source profile configured. Cannot transfer." + } + + try: + from .neo4j_client import get_neo4j_client, Neo4jClient + from scidk.core.settings import get_setting + + # Get source client + profile_key = f'neo4j_profile_{source_profile.replace(" ", "_")}' + profile_json = get_setting(profile_key) + if not profile_json: + return { + 'status': 'error', + 'error': f"Source profile '{source_profile}' not found" + } + + profile = json.loads(profile_json) + password_key = f'neo4j_profile_password_{source_profile.replace(" ", "_")}' + password = get_setting(password_key) + + source_client = Neo4jClient( + uri=profile.get('uri'), + user=profile.get('user'), + password=password, + database=profile.get('database', 'neo4j'), + auth_mode='basic' + ) + source_client.connect() + + # Get primary client + primary_client = get_neo4j_client(role='primary') + if not primary_client: + source_client.close() + return { + 'status': 'error', + 'error': 'Primary Neo4j connection not configured' + } + + try: + # Determine matching key (first required property or default to 'id') + matching_key = None + for prop in label_def.get('properties', []): + if prop.get('required'): + matching_key = prop.get('name') + break + if not matching_key: + matching_key = 'id' # Fallback + + # Phase 1: Transfer nodes in batches + offset = 0 + total_transferred = 0 + node_mapping = {} # Maps source_id -> primary_id + + while True: + # Pull batch from source + batch_query = f""" + MATCH (n:{name}) + RETURN elementId(n) as source_id, properties(n) as props + SKIP $offset + LIMIT $batch_size + """ + batch = source_client.execute_read(batch_query, { + 'offset': offset, + 'batch_size': batch_size + }) + + if not batch: + break + + # Create nodes in primary + for record in batch: + source_id = record.get('source_id') + props = record.get('props', {}) + + # Merge node in primary using matching key + merge_query = f""" + MERGE (n:{name} {{{matching_key}: $key_value}}) + SET n = $props + RETURN elementId(n) as primary_id + """ + + key_value = props.get(matching_key) + if not key_value: + # Skip nodes without matching key + continue + + result = primary_client.execute_write(merge_query, { + 'key_value': key_value, + 'props': props + }) + + if result: + primary_id = result[0].get('primary_id') + node_mapping[source_id] = primary_id + total_transferred += 1 + + offset += batch_size + + # Phase 2: Transfer relationships + relationships = label_def.get('relationships', []) + total_rels_transferred = 0 + + for rel in relationships: + rel_type = rel.get('type') + target_label = rel.get('target_label') + + # Query relationships from source + rel_query = f""" + MATCH (source:{name})-[r:{rel_type}]->(target:{target_label}) + RETURN elementId(source) as source_id, + properties(source) as source_props, + properties(target) as target_props, + properties(r) as rel_props + """ + + rel_batch = source_client.execute_read(rel_query) + + for rel_record in rel_batch: + source_props = rel_record.get('source_props', {}) + target_props = rel_record.get('target_props', {}) + rel_props = rel_record.get('rel_props', {}) + + # Get matching keys for source and target + source_key = source_props.get(matching_key) + target_key = target_props.get(matching_key) + + if not source_key or not target_key: + continue + + # Create relationship in primary + create_rel_query = f""" + MATCH (source:{name} {{{matching_key}: $source_key}}) + MATCH (target:{target_label} {{{matching_key}: $target_key}}) + MERGE (source)-[r:{rel_type}]->(target) + SET r = $rel_props + """ + + primary_client.execute_write(create_rel_query, { + 'source_key': source_key, + 'target_key': target_key, + 'rel_props': rel_props + }) + + total_rels_transferred += 1 + + return { + 'status': 'success', + 'nodes_transferred': total_transferred, + 'relationships_transferred': total_rels_transferred, + 'source_profile': source_profile, + 'matching_key': matching_key + } + + finally: + source_client.close() + + except Exception as e: + return { + 'status': 'error', + 'error': str(e) + } diff --git a/scidk/ui/templates/labels.html b/scidk/ui/templates/labels.html index de868eb..bf9b21b 100644 --- a/scidk/ui/templates/labels.html +++ b/scidk/ui/templates/labels.html @@ -300,6 +300,13 @@ background: #fff3e0; color: #f57c00; } + + .source-badge.neo4j { + background: #e1f5fe; + color: #0277bd; + border: 1px solid #b3e5fc; + } + .property-row, .relationship-row { display: flex; gap: 0.5rem; @@ -400,9 +407,13 @@

Labels

Labels

- - - +
+ + + +
+ +
@@ -511,6 +522,7 @@

Incoming Relationships

Instances

+ @@ -650,6 +662,70 @@
Confirm Action
+ + + From 5713bb540a1309b24bb1fdde5c97189fd5a71c9c Mon Sep 17 00:00:00 2001 From: Adam Patch Date: Thu, 19 Feb 2026 09:13:42 -0500 Subject: [PATCH 17/20] feat(ui): Redesign files page with tree explorer and modern layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete overhaul of the datasets/files page with new tree-based navigation and improved user experience. **New Features:** - Left sidebar tree explorer with collapsible folders - Tree search functionality for quick navigation - Resizable panels with collapse/expand - Right panel for file details/preview - Breadcrumb navigation - Modern card-based layout - Full-width responsive design **Tree Explorer:** - Hierarchical folder structure - Expandable/collapsible nodes - Visual icons for folders and files - Selected state highlighting - Search filter for tree nodes **Layout:** - Left panel: Tree navigation (25% width, resizable) - Right panel: File details and actions (75% width) - Collapsible sidebar (β†’/← toggle) - Full viewport height utilization - Responsive breakpoints for mobile **UX Improvements:** - Faster navigation through tree structure - Visual feedback for selections - Sticky search bar - Smooth transitions and animations - Better use of screen real estate **Settings Integration:** - Added "File Providers" to settings navigation - Seamless integration with provider configuration This modernizes the file browsing experience and prepares for advanced features like multi-select, batch operations, and inline previews. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scidk/ui/templates/datasets.html | 2322 +++++++++++++++++++++--------- scidk/ui/templates/index.html | 2 + 2 files changed, 1627 insertions(+), 697 deletions(-) diff --git a/scidk/ui/templates/datasets.html b/scidk/ui/templates/datasets.html index 14255d9..e55b77b 100644 --- a/scidk/ui/templates/datasets.html +++ b/scidk/ui/templates/datasets.html @@ -1,786 +1,1714 @@ {% extends 'base.html' %} {% block title %}-SciDK-> Files{% endblock %} +{% block head %} + +{% endblock %} {% block content %} -

Files

-
-

Files

-
-
- - -
-
- - -
-
- - +
+

Files

+
+
+ Neo4j: Not connected +
+
+ +
+ +
+ + + -
- -
-
- - -
-
- - -
-
- Max depth - -
+ + +
+
+ + + +
-
- + +
+
+
SERVERS
+
+
+ + + +
+
BACKGROUND TASKS
+
+
-
-
-
- - - -
NameTypeSizeModifiedProvider
+ + +
+ + +
+
+

File Browser

-
-
-
- Current Location: -
- No folder selected + + + + + +
+ Advanced Options +
+
+
+
+ + +
-
-
- -
- Select a folder to enable scanning. All scans run as background tasks with progress tracking. +
+
+ + +
+
+
+
+ Max depth + +
+
+ + +
+ + + + + + + + + + + + + + + +
NameTypeSizeCreatedModifiedScanned
Select a server to browse files
-
-
-{% if files_viewer == 'rocrate' %} -
-

RO-Crate Viewer

-
-
- - + +
+ + +
-
- -
This experimental viewer uses a minimal JSON-LD from /api/rocrate (or wrapper). Large folders may be truncated.
-
-{% else %} - -{% endif %} -
-

Snapshot (scanned) browse

-
-
- - -
-
- - -
-
- -
-
- Type - -
-
- Ext - -
-
- Page size - -
+ +
+ + + -
-
-
- - - -
NameTypeSizeModified
-
-
- -
- - -
-
-
-
- Ext - -
-
-
-
- Prefix - +
+ + +{% if files_viewer == 'rocrate' %} +
+
+ RO-Crate Viewer (Experimental) +
+
+ + +
+ +

This experimental viewer uses a minimal JSON-LD from /api/rocrate. Large folders may be truncated.

-
-
-
- -
-

Scans Summary

-
- - - - - -
IDPathFilesRecursiveStartedEndedCommitted
-
-
-
-

Scans Summary

-
- -
- - - - - {% if selected_scan %} - Filtering by scan {{ selected_scan.id }} for {{ selected_scan.path }} (recursive: {{ selected_scan.recursive }}) β€” Clear filter - {% endif %} -
- -
-
- Neo4j: Not connected -
-
- - {% if directories %} -
- Previously scanned sources (this session) -
    - {% for d in directories %} -
  • {% if d.provider_id %}{{ d.provider_id }} {% endif %}{{ d.path }} β€” files: {{ d.scanned }}, recursive: {{ d.recursive }}{% if d.source %} β€” {{ d.source }}{% endif %}
  • - {% endfor %} -
-
- {% endif %} -
+ + +{% endif %} -{% endblock %} -{% block head %} {% endblock %} \ No newline at end of file diff --git a/scidk/ui/templates/index.html b/scidk/ui/templates/index.html index a9f0f2e..953cf10 100644 --- a/scidk/ui/templates/index.html +++ b/scidk/ui/templates/index.html @@ -84,6 +84,7 @@