Bläddra i källkod

add sort and filter

tuanchris 5 månader sedan
förälder
incheckning
cd2847da55
6 ändrade filer med 754 tillägg och 91 borttagningar
  1. 60 0
      main.py
  2. 137 12
      modules/core/cache_manager.py
  3. 283 46
      static/js/index.js
  4. 219 25
      static/js/playlists.js
  5. 32 7
      templates/index.html
  6. 23 1
      templates/playlists.html

+ 60 - 0
main.py

@@ -269,6 +269,66 @@ async def list_theta_rho_files():
     files = pattern_manager.list_theta_rho_files()
     return sorted(files)
 
+@app.get("/list_theta_rho_files_with_metadata")
+async def list_theta_rho_files_with_metadata():
+    """Get list of theta-rho files with metadata for sorting and filtering."""
+    from modules.core.cache_manager import get_pattern_metadata
+    
+    files = pattern_manager.list_theta_rho_files()
+    files_with_metadata = []
+    
+    for file_path in files:
+        try:
+            full_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path)
+            
+            # Get file stats
+            file_stat = os.stat(full_path)
+            
+            # Get cached metadata
+            metadata = get_pattern_metadata(file_path)
+            
+            # Extract full folder path from file path
+            path_parts = file_path.split('/')
+            if len(path_parts) > 1:
+                # Get everything except the filename (join all folder parts)
+                category = '/'.join(path_parts[:-1])
+            else:
+                category = 'root'
+            
+            # Get file name without extension
+            file_name = os.path.splitext(os.path.basename(file_path))[0]
+            
+            # Use modification time (mtime) for "date modified"
+            date_modified = file_stat.st_mtime
+            
+            file_info = {
+                'path': file_path,
+                'name': file_name,
+                'category': category,
+                'date_modified': date_modified,
+                'coordinates_count': metadata.get('total_coordinates', 0) if metadata else 0
+            }
+            
+            files_with_metadata.append(file_info)
+            
+        except Exception as e:
+            logger.warning(f"Error getting metadata for {file_path}: {str(e)}")
+            # Include file with minimal info if metadata fails
+            path_parts = file_path.split('/')
+            if len(path_parts) > 1:
+                category = '/'.join(path_parts[:-1])
+            else:
+                category = 'root'
+            files_with_metadata.append({
+                'path': file_path,
+                'name': os.path.splitext(os.path.basename(file_path))[0],
+                'category': category,
+                'date_modified': 0,
+                'coordinates_count': 0
+            })
+    
+    return files_with_metadata
+
 @app.post("/upload_theta_rho")
 async def upload_theta_rho(file: UploadFile = File(...)):
     """Upload a theta-rho file."""

+ 137 - 12
modules/core/cache_manager.py

@@ -22,6 +22,86 @@ cache_progress = {
 CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
 METADATA_CACHE_FILE = "metadata_cache.json"  # Now in root directory
 
+# Cache schema version - increment when structure changes
+CACHE_SCHEMA_VERSION = 1
+
+# Expected cache schema structure
+EXPECTED_CACHE_SCHEMA = {
+    'version': CACHE_SCHEMA_VERSION,
+    'structure': {
+        'mtime': 'number',
+        'metadata': {
+            'first_coordinate': {'x': 'number', 'y': 'number'},
+            'last_coordinate': {'x': 'number', 'y': 'number'},
+            'total_coordinates': 'number'
+        }
+    }
+}
+
+def validate_cache_schema(cache_data):
+    """Validate that cache data matches the expected schema structure."""
+    try:
+        # Check if version info exists
+        if not isinstance(cache_data, dict):
+            return False
+        
+        # Check for version field - if missing, it's old format
+        cache_version = cache_data.get('version')
+        if cache_version is None:
+            logger.info("Cache file missing version info - treating as outdated schema")
+            return False
+        
+        # Check if version matches current expected version
+        if cache_version != CACHE_SCHEMA_VERSION:
+            logger.info(f"Cache schema version mismatch: found {cache_version}, expected {CACHE_SCHEMA_VERSION}")
+            return False
+        
+        # Check if data section exists
+        if 'data' not in cache_data:
+            logger.warning("Cache file missing 'data' section")
+            return False
+        
+        # Validate structure of a few entries if they exist
+        data_section = cache_data.get('data', {})
+        if data_section and isinstance(data_section, dict):
+            # Check first entry structure
+            for pattern_file, entry in list(data_section.items())[:1]:  # Just check first entry
+                if not isinstance(entry, dict):
+                    return False
+                if 'mtime' not in entry or 'metadata' not in entry:
+                    return False
+                metadata = entry.get('metadata', {})
+                required_fields = ['first_coordinate', 'last_coordinate', 'total_coordinates']
+                if not all(field in metadata for field in required_fields):
+                    return False
+                # Validate coordinate structure
+                for coord_field in ['first_coordinate', 'last_coordinate']:
+                    coord = metadata.get(coord_field)
+                    if not isinstance(coord, dict) or 'x' not in coord or 'y' not in coord:
+                        return False
+        
+        return True
+    except Exception as e:
+        logger.warning(f"Error validating cache schema: {str(e)}")
+        return False
+
+def invalidate_cache():
+    """Delete only the metadata cache file, preserving image cache."""
+    try:
+        # Delete metadata cache file only
+        if os.path.exists(METADATA_CACHE_FILE):
+            os.remove(METADATA_CACHE_FILE)
+            logger.info("Deleted outdated metadata cache file")
+        
+        # Keep image cache directory intact - images are still valid
+        # Just ensure the cache directory structure exists
+        ensure_cache_dir()
+        
+        return True
+    except Exception as e:
+        logger.error(f"Failed to invalidate metadata cache: {str(e)}")
+        return False
+
 def ensure_cache_dir():
     """Ensure the cache directory exists with proper permissions."""
     try:
@@ -29,8 +109,12 @@ def ensure_cache_dir():
         
         # Initialize metadata cache if it doesn't exist
         if not os.path.exists(METADATA_CACHE_FILE):
+            initial_cache = {
+                'version': CACHE_SCHEMA_VERSION,
+                'data': {}
+            }
             with open(METADATA_CACHE_FILE, 'w') as f:
-                json.dump({}, f)
+                json.dump(initial_cache, f)
             try:
                 os.chmod(METADATA_CACHE_FILE, 0o644)  # More conservative permissions
             except (OSError, PermissionError) as e:
@@ -94,8 +178,10 @@ def delete_pattern_cache(pattern_file):
         
         # Remove from metadata cache
         metadata_cache = load_metadata_cache()
-        if pattern_file in metadata_cache:
-            del metadata_cache[pattern_file]
+        data_section = metadata_cache.get('data', {})
+        if pattern_file in data_section:
+            del data_section[pattern_file]
+            metadata_cache['data'] = data_section
             save_metadata_cache(metadata_cache)
             logger.info(f"Removed {pattern_file} from metadata cache")
         
@@ -105,31 +191,68 @@ def delete_pattern_cache(pattern_file):
         return False
 
 def load_metadata_cache():
-    """Load the metadata cache from disk."""
+    """Load the metadata cache from disk with schema validation."""
     try:
         if os.path.exists(METADATA_CACHE_FILE):
             with open(METADATA_CACHE_FILE, 'r') as f:
-                return json.load(f)
+                cache_data = json.load(f)
+            
+            # Validate schema
+            if not validate_cache_schema(cache_data):
+                logger.info("Cache schema validation failed - invalidating cache")
+                invalidate_cache()
+                # Return empty cache structure after invalidation
+                return {
+                    'version': CACHE_SCHEMA_VERSION,
+                    'data': {}
+                }
+            
+            return cache_data
     except Exception as e:
-        logger.warning(f"Failed to load metadata cache: {str(e)}")
-    return {}
+        logger.warning(f"Failed to load metadata cache: {str(e)} - invalidating cache")
+        try:
+            invalidate_cache()
+        except Exception as invalidate_error:
+            logger.error(f"Failed to invalidate corrupted cache: {str(invalidate_error)}")
+    
+    # Return empty cache structure
+    return {
+        'version': CACHE_SCHEMA_VERSION,
+        'data': {}
+    }
 
 def save_metadata_cache(cache_data):
-    """Save the metadata cache to disk."""
+    """Save the metadata cache to disk with version info."""
     try:
         ensure_cache_dir()
+        
+        # Ensure cache data has proper structure
+        if not isinstance(cache_data, dict) or 'version' not in cache_data:
+            # Convert old format or create new structure
+            if isinstance(cache_data, dict) and 'data' not in cache_data:
+                # Old format - wrap existing data
+                structured_cache = {
+                    'version': CACHE_SCHEMA_VERSION,
+                    'data': cache_data
+                }
+            else:
+                structured_cache = cache_data
+        else:
+            structured_cache = cache_data
+        
         with open(METADATA_CACHE_FILE, 'w') as f:
-            json.dump(cache_data, f, indent=2)
+            json.dump(structured_cache, f, indent=2)
     except Exception as e:
         logger.error(f"Failed to save metadata cache: {str(e)}")
 
 def get_pattern_metadata(pattern_file):
     """Get cached metadata for a pattern file."""
     cache_data = load_metadata_cache()
+    data_section = cache_data.get('data', {})
     
     # Check if we have cached metadata and if the file hasn't changed
-    if pattern_file in cache_data:
-        cached_entry = cache_data[pattern_file]
+    if pattern_file in data_section:
+        cached_entry = data_section[pattern_file]
         pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
         
         try:
@@ -145,10 +268,11 @@ def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
     """Cache metadata for a pattern file."""
     try:
         cache_data = load_metadata_cache()
+        data_section = cache_data.get('data', {})
         pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
         file_mtime = os.path.getmtime(pattern_path)
         
-        cache_data[pattern_file] = {
+        data_section[pattern_file] = {
             'mtime': file_mtime,
             'metadata': {
                 'first_coordinate': first_coord,
@@ -157,6 +281,7 @@ def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
             }
         }
         
+        cache_data['data'] = data_section
         save_metadata_cache(cache_data)
         logger.debug(f"Cached metadata for {pattern_file}")
     except Exception as e:

+ 283 - 46
static/js/index.js

@@ -1,5 +1,8 @@
 // Global variables
 let allPatterns = [];
+let allPatternsWithMetadata = []; // Enhanced pattern data with metadata
+let currentSort = { field: 'name', direction: 'asc' };
+let currentFilters = { category: 'all' };
 
 // Helper function to normalize file paths for cross-platform compatibility
 function normalizeFilePath(filePath) {
@@ -639,33 +642,52 @@ async function loadPatterns(forceRefresh = false) {
     try {
         logMessage('Loading patterns...', LOG_TYPE.INFO);
         
-        logMessage('Fetching fresh patterns list from server', LOG_TYPE.DEBUG);
-        const response = await fetch('/list_theta_rho_files');
-        const allFiles = await response.json();
-        logMessage(`Received ${allFiles.length} files from server`, LOG_TYPE.INFO);
-
-        // Filter for .thr files
-        let patterns = allFiles.filter(file => file.endsWith('.thr'));
-        logMessage(`Filtered to ${patterns.length} .thr files`, LOG_TYPE.INFO);
-        if (forceRefresh) {
-            showStatusMessage('Patterns list refreshed successfully', 'success');
-        }
+        // First load basic patterns list for fast initial display
+        logMessage('Fetching basic patterns list from server', LOG_TYPE.DEBUG);
+        const basicResponse = await fetch('/list_theta_rho_files');
+        const basicPatterns = await basicResponse.json();
+        const thrPatterns = basicPatterns.filter(file => file.endsWith('.thr'));
+        logMessage(`Received ${thrPatterns.length} basic patterns from server`, LOG_TYPE.INFO);
         
-        // Sort patterns with custom_patterns on top and all alphabetically sorted
-        const sortedPatterns = patterns.sort((a, b) => {
-            const isCustomA = a.startsWith('custom_patterns/');
-            const isCustomB = b.startsWith('custom_patterns/');
-
-            if (isCustomA && !isCustomB) return -1;
-            if (!isCustomA && isCustomB) return 1;
-            return a.localeCompare(b);
-        });
+        // Store basic patterns and display immediately
+        let patterns = [...thrPatterns];
+        allPatterns = patterns;
+        
+        // Sort patterns alphabetically to match final enhanced sorting
+        const sortedPatterns = patterns.sort((a, b) => a.localeCompare(b));
 
         allPatterns = sortedPatterns;
-        currentBatch = 0;
-        logMessage('Displaying initial batch of patterns...', LOG_TYPE.INFO);
+        
+        // Display basic patterns immediately for fast initial load
+        logMessage('Displaying initial patterns...', LOG_TYPE.INFO);
         displayPatternBatch();
-        logMessage('Initial batch loaded successfully.', LOG_TYPE.SUCCESS);
+        logMessage('Initial patterns loaded successfully.', LOG_TYPE.SUCCESS);
+        
+        // Load metadata in background for enhanced features
+        setTimeout(async () => {
+            try {
+            logMessage('Loading enhanced metadata...', LOG_TYPE.DEBUG);
+            const metadataResponse = await fetch('/list_theta_rho_files_with_metadata');
+            const patternsWithMetadata = await metadataResponse.json();
+            
+            // Store enhanced patterns data
+            allPatternsWithMetadata = [...patternsWithMetadata];
+            
+            // Update category filter dropdown now that we have metadata
+            updateBrowseCategoryFilter();
+            
+            // Enable sort controls and display patterns consistently
+            enableSortControls();
+            
+            logMessage(`Enhanced metadata loaded for ${patternsWithMetadata.length} patterns`, LOG_TYPE.SUCCESS);
+            } catch (metadataError) {
+                logMessage(`Failed to load enhanced metadata: ${metadataError.message}`, LOG_TYPE.WARNING);
+                // No fallback needed - basic patterns already displayed
+            }
+        }, 100); // Small delay to let initial render complete
+        if (forceRefresh) {
+            showStatusMessage('Patterns list refreshed successfully', 'success');
+        }
     } catch (error) {
         logMessage(`Error loading patterns: ${error.message}`, LOG_TYPE.ERROR);
         console.error('Full error:', error);
@@ -1103,47 +1125,247 @@ function setupPreviewPanelEvents(pattern) {
 }
 
 // Search patterns
-function searchPatterns(query) {
-    if (!query) {
-        // If search is empty, clear grid and show all patterns
-        const patternGrid = document.querySelector('.grid');
-        if (patternGrid) {
-            patternGrid.innerHTML = '';
+// Sort patterns by specified field and direction
+function sortPatterns(patterns, sortField, sortDirection) {
+    return patterns.sort((a, b) => {
+        let aVal, bVal;
+        
+        switch (sortField) {
+            case 'name':
+                aVal = a.name.toLowerCase();
+                bVal = b.name.toLowerCase();
+                break;
+            case 'date':
+                aVal = a.date_modified;
+                bVal = b.date_modified;
+                break;
+            case 'coordinates':
+                aVal = a.coordinates_count;
+                bVal = b.coordinates_count;
+                break;
+            default:
+                aVal = a.name.toLowerCase();
+                bVal = b.name.toLowerCase();
+        }
+        
+        let result = 0;
+        if (aVal < bVal) result = -1;
+        else if (aVal > bVal) result = 1;
+        
+        return sortDirection === 'asc' ? result : -result;
+    });
+}
+
+// Filter patterns based on current filters
+function filterPatterns(patterns, filters, searchQuery = '') {
+    return patterns.filter(pattern => {
+        // Category filter
+        if (filters.category !== 'all' && pattern.category !== filters.category) {
+            return false;
+        }
+        
+        // Search query filter
+        if (searchQuery.trim()) {
+            const normalizedQuery = searchQuery.toLowerCase().trim();
+            const patternName = pattern.name.toLowerCase();
+            const category = pattern.category.toLowerCase();
+            return patternName.includes(normalizedQuery) || category.includes(normalizedQuery);
+        }
+        
+        return true;
+    });
+}
+
+// Apply sorting and filtering to patterns
+function applyPatternsFilteringAndSorting() {
+    const searchQuery = document.getElementById('patternSearch')?.value || '';
+    
+    // Check if enhanced metadata is available
+    if (!allPatternsWithMetadata || allPatternsWithMetadata.length === 0) {
+        // Fallback to basic search if metadata not loaded yet
+        if (searchQuery.trim()) {
+            const filteredPatterns = allPatterns.filter(pattern => 
+                pattern.toLowerCase().includes(searchQuery.toLowerCase())
+            );
+            displayFilteredPatterns(filteredPatterns);
+        } else {
+            // Just display current batch if no search
+            displayPatternBatch();
         }
-        // Reset current batch and display from beginning
-        currentBatch = 0;
-        displayPatternBatch();
         return;
     }
+    
+    // Start with all available patterns with metadata
+    let patterns = [...allPatternsWithMetadata];
+    
+    // Apply filters
+    patterns = filterPatterns(patterns, currentFilters, searchQuery);
+    
+    // Apply sorting
+    patterns = sortPatterns(patterns, currentSort.field, currentSort.direction);
+    
+    // Update filtered patterns (convert back to path format for compatibility)
+    const filteredPatterns = patterns.map(p => p.path);
+    
+    // Display filtered patterns
+    displayFilteredPatterns(filteredPatterns);
+    updateBrowseSortAndFilterUI();
+}
 
-    const searchInput = query.toLowerCase();
+// Display filtered patterns
+function displayFilteredPatterns(filteredPatterns) {
     const patternGrid = document.querySelector('.grid');
-    if (!patternGrid) {
-        logMessage('Pattern grid not found in the DOM', LOG_TYPE.ERROR);
+    if (!patternGrid) return;
+    
+    patternGrid.innerHTML = '';
+    
+    if (filteredPatterns.length === 0) {
+        patternGrid.innerHTML = '<div class="col-span-full text-center text-gray-500 py-8">No patterns found</div>';
         return;
     }
-
-    // Clear existing patterns
-    patternGrid.innerHTML = '';
     
-    // Filter patterns
-    const filteredPatterns = allPatterns.filter(pattern => 
-        pattern.toLowerCase().includes(searchInput)
-    );
-
-    // Display filtered patterns
     filteredPatterns.forEach(pattern => {
         const patternCard = createPatternCard(pattern);
         patternGrid.appendChild(patternCard);
     });
-
+    
     // Give the browser a chance to render the cards
     requestAnimationFrame(() => {
         // Trigger preview loading for the search results
         triggerPreviewLoadingForVisible();
     });
+    
+    logMessage(`Displaying ${filteredPatterns.length} patterns`, LOG_TYPE.INFO);
+}
+
+function searchPatterns(query) {
+    // Update the search input if called programmatically
+    const searchInput = document.getElementById('patternSearch');
+    if (searchInput && searchInput.value !== query) {
+        searchInput.value = query;
+    }
+    
+    applyPatternsFilteringAndSorting();
+}
+
+// Update sort and filter UI to reflect current state
+function updateBrowseSortAndFilterUI() {
+    // Update sort direction icon
+    const sortDirectionIcon = document.getElementById('browseSortDirectionIcon');
+    if (sortDirectionIcon) {
+        sortDirectionIcon.textContent = currentSort.direction === 'asc' ? 'arrow_upward' : 'arrow_downward';
+    }
+    
+    // Update sort field select
+    const sortFieldSelect = document.getElementById('browseSortFieldSelect');
+    if (sortFieldSelect) {
+        sortFieldSelect.value = currentSort.field;
+    }
+    
+    // Update filter selects
+    const categorySelect = document.getElementById('browseCategoryFilterSelect');
+    if (categorySelect) {
+        categorySelect.value = currentFilters.category;
+    }
+}
+
+// Populate category filter dropdown with available categories (subfolders)
+function updateBrowseCategoryFilter() {
+    const categorySelect = document.getElementById('browseCategoryFilterSelect');
+    if (!categorySelect) return;
+    
+    // Check if metadata is available
+    if (!allPatternsWithMetadata || allPatternsWithMetadata.length === 0) {
+        // Show basic options if metadata not loaded
+        categorySelect.innerHTML = '<option value="all">All Folders (loading...)</option>';
+        return;
+    }
+    
+    // Get unique categories (subfolders)
+    const categories = [...new Set(allPatternsWithMetadata.map(p => p.category))].sort();
+    
+    // Clear existing options except "All"
+    categorySelect.innerHTML = '<option value="all">All Folders</option>';
+    
+    // Add category options
+    categories.forEach(category => {
+        if (category) {
+            const option = document.createElement('option');
+            option.value = category;
+            // Display friendly names for full paths
+            if (category === 'root') {
+                option.textContent = 'Root Folder';
+            } else {
+                // For full paths, show the path but make it more readable
+                const displayName = category
+                    .split('/')
+                    .map(part => part.charAt(0).toUpperCase() + part.slice(1).replace('_', ' '))
+                    .join(' › ');
+                option.textContent = displayName;
+            }
+            categorySelect.appendChild(option);
+        }
+    });
+}
 
-    logMessage(`Showing ${filteredPatterns.length} patterns matching "${query}"`, LOG_TYPE.INFO);
+// Handle sort field change
+function handleBrowseSortFieldChange() {
+    const sortFieldSelect = document.getElementById('browseSortFieldSelect');
+    if (sortFieldSelect) {
+        currentSort.field = sortFieldSelect.value;
+        applyPatternsFilteringAndSorting();
+    }
+}
+
+// Handle sort direction toggle
+function handleBrowseSortDirectionToggle() {
+    currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
+    applyPatternsFilteringAndSorting();
+}
+
+// Handle category filter change
+function handleBrowseCategoryFilterChange() {
+    const categorySelect = document.getElementById('browseCategoryFilterSelect');
+    if (categorySelect) {
+        currentFilters.category = categorySelect.value;
+        applyPatternsFilteringAndSorting();
+    }
+}
+
+// Enable sort controls when metadata is loaded
+function enableSortControls() {
+    const browseSortFieldSelect = document.getElementById('browseSortFieldSelect');
+    const browseSortDirectionBtn = document.getElementById('browseSortDirectionBtn');
+    const browseCategoryFilterSelect = document.getElementById('browseCategoryFilterSelect');
+    
+    if (browseSortFieldSelect) {
+        browseSortFieldSelect.disabled = false;
+        // Ensure dropdown shows the current sort field
+        browseSortFieldSelect.value = currentSort.field;
+    }
+    
+    if (browseSortDirectionBtn) {
+        browseSortDirectionBtn.disabled = false;
+        browseSortDirectionBtn.classList.remove('opacity-50', 'cursor-not-allowed');
+        browseSortDirectionBtn.classList.add('hover:bg-gray-200');
+        browseSortDirectionBtn.title = 'Toggle sort direction';
+        
+        // Update direction icon
+        const sortDirectionIcon = document.getElementById('browseSortDirectionIcon');
+        if (sortDirectionIcon) {
+            sortDirectionIcon.textContent = currentSort.direction === 'asc' ? 'arrow_upward' : 'arrow_downward';
+        }
+    }
+    
+    if (browseCategoryFilterSelect) {
+        browseCategoryFilterSelect.disabled = false;
+    }
+    
+    // Only apply sorting if user has changed from defaults or if patterns need to be refreshed
+    // If already showing patterns with default sort (name, asc), don't reorder unnecessarily
+    if (currentSort.field !== 'name' || currentSort.direction !== 'asc' || currentFilters.category !== 'all') {
+        applyPatternsFilteringAndSorting();
+    }
 }
 
 // Filter patterns by category
@@ -1197,6 +1419,21 @@ document.addEventListener('DOMContentLoaded', async () => {
                 }
             });
         }
+        
+        // Sort and filter controls for browse page
+        const browseSortFieldSelect = document.getElementById('browseSortFieldSelect');
+        const browseSortDirectionBtn = document.getElementById('browseSortDirectionBtn');
+        const browseCategoryFilterSelect = document.getElementById('browseCategoryFilterSelect');
+        
+        if (browseSortFieldSelect) {
+            browseSortFieldSelect.addEventListener('change', handleBrowseSortFieldChange);
+        }
+        if (browseSortDirectionBtn) {
+            browseSortDirectionBtn.addEventListener('click', handleBrowseSortDirectionToggle);
+        }
+        if (browseCategoryFilterSelect) {
+            browseCategoryFilterSelect.addEventListener('change', handleBrowseCategoryFilterChange);
+        }
 
         // Setup cache all button - now triggers the modal
         if (cacheAllButton) {

+ 219 - 25
static/js/playlists.js

@@ -11,12 +11,17 @@ const LOG_TYPE = {
 let allPlaylists = [];
 let currentPlaylist = null;
 let availablePatterns = [];
+let availablePatternsWithMetadata = []; // Enhanced pattern data with metadata
 let filteredPatterns = [];
 let selectedPatterns = new Set();
 let previewCache = new Map();
 let intersectionObserver = null;
 let searchTimeout = null;
 
+// Sorting and filtering state
+let currentSort = { field: 'name', direction: 'asc' };
+let currentFilters = { category: 'all' };
+
 // Mobile navigation state
 let isMobileView = false;
 
@@ -655,7 +660,6 @@ async function displayPlaylistPatterns(patterns) {
 
     // Clear grid and add all pattern cards
     patternsGrid.innerHTML = '';
-    const visiblePatterns = new Map();
     
     patterns.forEach(pattern => {
         const patternCard = createPatternCard(pattern, true);
@@ -766,22 +770,182 @@ async function loadPreviewFromCache(pattern, previewContainer) {
     }
 }
 
-// Search and filter patterns
-function searchPatterns(query) {
-    const normalizedQuery = query.toLowerCase().trim();
+// Sort patterns by specified field and direction
+function sortPatterns(patterns, sortField, sortDirection) {
+    return patterns.sort((a, b) => {
+        let aVal, bVal;
+        
+        switch (sortField) {
+            case 'name':
+                aVal = a.name.toLowerCase();
+                bVal = b.name.toLowerCase();
+                break;
+            case 'date':
+                aVal = a.date_modified;
+                bVal = b.date_modified;
+                break;
+            case 'coordinates':
+                aVal = a.coordinates_count;
+                bVal = b.coordinates_count;
+                break;
+            default:
+                aVal = a.name.toLowerCase();
+                bVal = b.name.toLowerCase();
+        }
+        
+        let result = 0;
+        if (aVal < bVal) result = -1;
+        else if (aVal > bVal) result = 1;
+        
+        return sortDirection === 'asc' ? result : -result;
+    });
+}
+
+// Filter patterns based on current filters
+function filterPatterns(patterns, filters, searchQuery = '') {
+    return patterns.filter(pattern => {
+        // Category filter
+        if (filters.category !== 'all' && pattern.category !== filters.category) {
+            return false;
+        }
+        
+        // Search query filter
+        if (searchQuery.trim()) {
+            const normalizedQuery = searchQuery.toLowerCase().trim();
+            const patternName = pattern.name.toLowerCase();
+            const category = pattern.category.toLowerCase();
+            return patternName.includes(normalizedQuery) || category.includes(normalizedQuery);
+        }
+        
+        return true;
+    });
+}
+
+// Apply sorting and filtering to patterns
+function applyPatternsFilteringAndSorting() {
+    const searchQuery = document.getElementById('patternSearchInput')?.value || '';
     
-    if (!normalizedQuery) {
-        filteredPatterns = [...availablePatterns];
-    } else {
-        filteredPatterns = availablePatterns.filter(pattern => {
-            const patternName = pattern.replace('.thr', '').split('/').pop().toLowerCase();
-            return patternName.includes(normalizedQuery);
-        });
+    // Check if enhanced metadata is available
+    if (!availablePatternsWithMetadata || availablePatternsWithMetadata.length === 0) {
+        // Fallback to basic search if metadata not loaded yet
+        if (searchQuery.trim()) {
+            filteredPatterns = availablePatterns.filter(pattern => 
+                pattern.toLowerCase().includes(searchQuery.toLowerCase())
+            );
+        } else {
+            filteredPatterns = [...availablePatterns];
+        }
+        displayAvailablePatterns();
+        return;
     }
     
+    // Start with all available patterns with metadata
+    let patterns = [...availablePatternsWithMetadata];
+    
+    // Apply filters
+    patterns = filterPatterns(patterns, currentFilters, searchQuery);
+    
+    // Apply sorting
+    patterns = sortPatterns(patterns, currentSort.field, currentSort.direction);
+    
+    // Update filtered patterns (convert back to path format for compatibility)
+    filteredPatterns = patterns.map(p => p.path);
+    
+    // Update display
     displayAvailablePatterns();
+    updateSortAndFilterUI();
+}
+
+// Search and filter patterns (updated to work with metadata)
+function searchPatterns(query) {
+    applyPatternsFilteringAndSorting();
+}
+
+// Update sort and filter UI to reflect current state
+function updateSortAndFilterUI() {
+    // Update sort direction icon
+    const sortDirectionIcon = document.getElementById('sortDirectionIcon');
+    if (sortDirectionIcon) {
+        sortDirectionIcon.textContent = currentSort.direction === 'asc' ? 'arrow_upward' : 'arrow_downward';
+    }
+    
+    // Update sort field select
+    const sortFieldSelect = document.getElementById('sortFieldSelect');
+    if (sortFieldSelect) {
+        sortFieldSelect.value = currentSort.field;
+    }
+    
+    // Update filter selects
+    const categorySelect = document.getElementById('categoryFilterSelect');
+    if (categorySelect) {
+        categorySelect.value = currentFilters.category;
+    }
+}
+
+// Populate category filter dropdown with available categories (subfolders)
+function updateCategoryFilter() {
+    const categorySelect = document.getElementById('categoryFilterSelect');
+    if (!categorySelect) return;
+    
+    // Check if metadata is available
+    if (!availablePatternsWithMetadata || availablePatternsWithMetadata.length === 0) {
+        // Show basic options if metadata not loaded
+        categorySelect.innerHTML = '<option value="all">All Folders (loading...)</option>';
+        return;
+    }
+    
+    // Get unique categories (subfolders)
+    const categories = [...new Set(availablePatternsWithMetadata.map(p => p.category))].sort();
+    
+    // Clear existing options except "All"
+    categorySelect.innerHTML = '<option value="all">All Folders</option>';
+    
+    // Add category options
+    categories.forEach(category => {
+        if (category) {
+            const option = document.createElement('option');
+            option.value = category;
+            // Display friendly names for full paths
+            if (category === 'root') {
+                option.textContent = 'Root Folder';
+            } else {
+                // For full paths, show the path but make it more readable
+                const displayName = category
+                    .split('/')
+                    .map(part => part.charAt(0).toUpperCase() + part.slice(1).replace('_', ' '))
+                    .join(' › ');
+                option.textContent = displayName;
+            }
+            categorySelect.appendChild(option);
+        }
+    });
+}
+
+// Handle sort field change
+function handleSortFieldChange() {
+    const sortFieldSelect = document.getElementById('sortFieldSelect');
+    if (sortFieldSelect) {
+        currentSort.field = sortFieldSelect.value;
+        applyPatternsFilteringAndSorting();
+    }
+}
+
+// Handle sort direction toggle
+function handleSortDirectionToggle() {
+    currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
+    applyPatternsFilteringAndSorting();
+}
+
+// Handle category filter change
+function handleCategoryFilterChange() {
+    const categorySelect = document.getElementById('categoryFilterSelect');
+    if (categorySelect) {
+        currentFilters.category = categorySelect.value;
+        applyPatternsFilteringAndSorting();
+    }
 }
 
+
 // Handle search input
 function handleSearchInput() {
     const searchInput = document.getElementById('patternSearchInput');
@@ -851,7 +1015,7 @@ async function removePatternFromPlaylist(pattern) {
     }
 }
 
-// Load available patterns for adding (no caching)
+// Load available patterns for adding (with metadata for sorting/filtering)
 async function loadAvailablePatterns(forceRefresh = false) {
     const loadingIndicator = document.getElementById('patternsLoadingIndicator');
     const grid = document.getElementById('availablePatternsGrid');
@@ -863,21 +1027,46 @@ async function loadAvailablePatterns(forceRefresh = false) {
     noResultsMessage.classList.add('hidden');
     
     try {
-        logMessage('Fetching fresh patterns list from server', LOG_TYPE.DEBUG);
-        const response = await fetch('/list_theta_rho_files');
-        if (response.ok) {
-            const patterns = await response.json();
-            const thrPatterns = patterns.filter(file => file.endsWith('.thr'));
-            availablePatterns = [...thrPatterns];
-            filteredPatterns = [...availablePatterns];
-            // Show patterns immediately - all lazy loading now
-            displayAvailablePatterns();
-            if (forceRefresh) {
-                showStatusMessage('Patterns list refreshed successfully', 'success');
-            }
-        } else {
+        // First load basic patterns list for fast initial display
+        logMessage('Fetching basic patterns list from server', LOG_TYPE.DEBUG);
+        const basicResponse = await fetch('/list_theta_rho_files');
+        if (!basicResponse.ok) {
             throw new Error('Failed to load available patterns');
         }
+        
+        const patterns = await basicResponse.json();
+        const thrPatterns = patterns.filter(file => file.endsWith('.thr'));
+        availablePatterns = [...thrPatterns];
+        filteredPatterns = [...availablePatterns];
+        
+        // Show patterns immediately for fast loading
+        displayAvailablePatterns();
+        
+        // Then load metadata in background
+        setTimeout(async () => {
+            try {
+                logMessage('Loading enhanced metadata in background', LOG_TYPE.DEBUG);
+                const metadataResponse = await fetch('/list_theta_rho_files_with_metadata');
+                if (metadataResponse.ok) {
+                    const patternsWithMetadata = await metadataResponse.json();
+                    availablePatternsWithMetadata = [...patternsWithMetadata];
+                    
+                    // Update category filter dropdown now that we have metadata
+                    updateCategoryFilter();
+                    
+                    logMessage(`Enhanced metadata loaded for ${patternsWithMetadata.length} patterns`, LOG_TYPE.DEBUG);
+                } else {
+                    logMessage('Failed to load enhanced metadata - using basic functionality', LOG_TYPE.WARNING);
+                }
+            } catch (metadataError) {
+                logMessage(`Error loading enhanced metadata: ${metadataError.message}`, LOG_TYPE.WARNING);
+                // Continue with basic functionality
+            }
+        }, 100);
+        
+        if (forceRefresh) {
+            showStatusMessage('Patterns list refreshed successfully', 'success');
+        }
     } catch (error) {
         logMessage(`Error loading available patterns: ${error.message}`, LOG_TYPE.ERROR);
         showStatusMessage('Failed to load available patterns', 'error');
@@ -1384,6 +1573,11 @@ function setupEventListeners() {
     document.getElementById('patternSearchInput').addEventListener('input', handleSearchInput);
     document.getElementById('clearSearchBtn').addEventListener('click', clearSearch);
     
+    // Sort and filter controls
+    document.getElementById('sortFieldSelect').addEventListener('change', handleSortFieldChange);
+    document.getElementById('sortDirectionBtn').addEventListener('click', handleSortDirectionToggle);
+    document.getElementById('categoryFilterSelect').addEventListener('change', handleCategoryFilterChange);
+    
     // Handle Enter key in search input
     document.getElementById('patternSearchInput').addEventListener('keypress', (e) => {
         if (e.key === 'Enter') {

+ 32 - 7
templates/index.html

@@ -189,13 +189,38 @@
                 Browse Patterns
             </h2>
 
-            <button
-                    id="cacheAllButton"
-                    class="inline-flex gap-2 transition-colors items-center justify-center rounded-lg px-4 py-2.5 text-xs sm:text-sm font-semibold text-gray-900 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
-            >
-                <span class="material-icons text-sm pr-2">cached</span>
-                Cache All Previews
-            </button>
+            <div class="flex items-center gap-2 sm:gap-4 flex-wrap">
+                <button
+                        id="cacheAllButton"
+                        class="inline-flex gap-1 sm:gap-2 transition-colors items-center justify-center rounded-lg px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm font-semibold text-gray-900 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 shrink-0"
+                >
+                    <span class="material-icons text-sm pr-1 sm:pr-2">cached</span>
+                    <span class="hidden sm:inline">Cache All Previews</span>
+                    <span class="sm:hidden">Cache</span>
+                </button>
+                
+                <!-- Sort and Filter Controls for Browse Page -->
+                <div class="flex gap-1 sm:gap-2 items-center text-xs sm:text-sm">
+                    <div class="flex items-center gap-1">
+                        <span class="text-xs font-medium text-gray-700 hidden sm:inline">Sort:</span>
+                        <select id="browseSortFieldSelect" class="text-xs rounded border border-gray-300 bg-white text-gray-900 px-1 sm:px-2 py-1" disabled>
+                            <option value="name">Name</option>
+                            <option value="date">Date Modified</option>
+                            <option value="coordinates">Coordinates</option>
+                        </select>
+                        <button id="browseSortDirectionBtn" class="p-1 rounded hover:bg-gray-200 text-gray-500 opacity-50 cursor-not-allowed" title="Loading..." disabled>
+                            <span class="material-icons text-sm" id="browseSortDirectionIcon">arrow_upward</span>
+                        </button>
+                    </div>
+                    
+                    <div class="flex items-center gap-1">
+                        <span class="text-xs font-medium text-gray-700 hidden sm:inline">Folder:</span>
+                        <select id="browseCategoryFilterSelect" class="text-xs rounded border border-gray-300 bg-white text-gray-900 px-1 sm:px-2 py-1" disabled>
+                            <option value="all">All Folders (loading...)</option>
+                        </select>
+                    </div>
+                </div>
+            </div>
 
             <div class="relative w-full sm:w-auto flex grow gap-2 items-center flex-wrap">
                 <div class="flex gap-2 flex-1">

+ 23 - 1
templates/playlists.html

@@ -294,7 +294,7 @@ html:not(.dark) #availablePatternsGrid .text-xs {
       <h3 id="modalTitle" class="text-lg font-semibold text-gray-900 dark:text-gray-100">Add Patterns to Playlist</h3>
     </div>
     
-    <!-- Search Bar and Select All -->
+    <!-- Search Bar and Controls -->
     <div class="mb-4 flex-shrink-0 space-y-3">
       <div class="relative">
         <span class="material-icons absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500 text-lg">search</span>
@@ -309,6 +309,28 @@ html:not(.dark) #availablePatternsGrid .text-xs {
         </button>
       </div>
       
+      <!-- Sort and Filter Controls -->
+      <div class="flex flex-wrap gap-3 items-center">
+        <div class="flex items-center gap-2">
+          <span class="text-xs font-medium text-gray-700 dark:text-gray-300">Sort by:</span>
+          <select id="sortFieldSelect" class="text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-2 py-1">
+            <option value="name">Name</option>
+            <option value="date">Date Modified</option>
+            <option value="coordinates">Coordinates</option>
+          </select>
+          <button id="sortDirectionBtn" class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400" title="Toggle sort direction">
+            <span class="material-icons text-sm" id="sortDirectionIcon">arrow_upward</span>
+          </button>
+        </div>
+        
+        <div class="flex items-center gap-2">
+          <span class="text-xs font-medium text-gray-700 dark:text-gray-300">Folder:</span>
+          <select id="categoryFilterSelect" class="text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-2 py-1">
+            <option value="all">All</option>
+          </select>
+        </div>
+      </div>
+      
       <!-- Smart Toggle Select All button -->
       <div class="flex items-center justify-between">
         <button