diff --git a/CHANGELOG.md b/CHANGELOG.md index b921c7a..6ad1008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to Library Manager will be documented in this file. +## [0.9.0-beta.122] - 2026-02-11 + +### Added + +- **Issue #111: Sortable columns** - Library table columns (Author, Title, Status) can now be + sorted by clicking column headers. Click once for ascending, again for descending, third click + clears sort back to default order. Visual arrow indicators show active sort column and direction. + Sort state preserved during pagination and filter changes. Backend validates sort columns against + a whitelist to prevent SQL injection. + +--- + ## [0.9.0-beta.121] - 2026-02-10 ### Fixed diff --git a/app.py b/app.py index 289577d..d31e6f0 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,7 @@ - Multi-provider AI (Gemini, OpenRouter, Ollama) """ -APP_VERSION = "0.9.0-beta.121" +APP_VERSION = "0.9.0-beta.122" GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo # Versioning Guide: @@ -8954,6 +8954,24 @@ def api_library(): per_page = request.args.get('per_page', 50, type=int) offset = (page - 1) * per_page + # Issue #111: Sortable columns + sort_by = request.args.get('sort', '')[:20] + sort_dir = request.args.get('sort_dir', 'asc') + if sort_dir not in ('asc', 'desc'): + sort_dir = 'asc' + sort_direction = 'ASC' if sort_dir == 'asc' else 'DESC' + + # Whitelist of sortable columns per table context + BOOK_SORT_COLS = {'author': 'current_author', 'title': 'current_title', 'status': 'status'} + HISTORY_SORT_COLS = {'author': 'h.old_author', 'title': 'h.old_title', 'status': 'h.status', 'date': 'h.fixed_at'} + QUEUE_SORT_COLS = {'author': 'b.current_author', 'title': 'b.current_title', 'priority': 'q.priority'} + + def build_order_by(sort_cols, default_order): + """Build ORDER BY clause from whitelisted sort columns or default.""" + if sort_by and sort_by in sort_cols: + return 'ORDER BY ' + sort_cols[sort_by] + ' ' + sort_direction + return 'ORDER BY ' + default_order + items = [] # Get search parameter @@ -9042,6 +9060,11 @@ def api_library(): # === FETCH ITEMS based on filter === if status_filter == 'orphan': + # Issue #111: Sort orphans if requested + if sort_by == 'author': + orphan_list.sort(key=lambda o: (o.get('author') or '').lower(), reverse=(sort_dir == 'desc')) + elif sort_by == 'title': + orphan_list.sort(key=lambda o: (o.get('detected_title') or '').lower(), reverse=(sort_dir == 'desc')) # Return orphans as items for idx, orphan in enumerate(orphan_list[offset:offset + per_page]): items.append({ @@ -9058,13 +9081,14 @@ def api_library(): elif status_filter == 'pending': # Items with pending fixes + order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC') c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title, h.old_path, h.new_path, h.status, h.fixed_at, h.error_message, b.path, b.current_author, b.current_title FROM history h JOIN books b ON h.book_id = b.id WHERE h.status = 'pending_fix' - ORDER BY h.fixed_at DESC + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9085,12 +9109,13 @@ def api_library(): elif status_filter == 'queue': # Items in the processing queue # Issue #36: Filter out series_folder and multi_book_files - they should never appear in queue + order = build_order_by(QUEUE_SORT_COLS, 'q.priority, q.added_at') c.execute('''SELECT q.id as queue_id, q.reason, q.added_at, q.priority, b.id as book_id, b.path, b.current_author, b.current_title, b.status FROM queue q JOIN books b ON q.book_id = b.id WHERE b.status NOT IN ('series_folder', 'multi_book_files', 'verified', 'fixed') - ORDER BY q.priority, q.added_at + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9108,13 +9133,14 @@ def api_library(): elif status_filter == 'fixed': # Successfully fixed items + order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC') c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title, h.old_path, h.new_path, h.status, h.fixed_at, b.path FROM history h JOIN books b ON h.book_id = b.id WHERE h.status = 'fixed' - ORDER BY h.fixed_at DESC + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9134,10 +9160,11 @@ def api_library(): elif status_filter == 'verified': # Verified/OK books - include profile for source display + order = build_order_by(BOOK_SORT_COLS, 'updated_at DESC') c.execute('''SELECT id, path, current_author, current_title, status, updated_at, profile, confidence, user_locked FROM books WHERE status = 'verified' - ORDER BY updated_at DESC + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): item = { @@ -9168,13 +9195,14 @@ def api_library(): elif status_filter == 'error': # Error items from history + order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC') c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title, h.old_path, h.new_path, h.status, h.fixed_at, h.error_message, b.path FROM history h JOIN books b ON h.book_id = b.id WHERE h.status IN ('error', 'duplicate', 'corrupt_dest') - ORDER BY h.fixed_at DESC + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9195,13 +9223,14 @@ def api_library(): elif status_filter == 'attention': # Items needing attention + order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC') c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title, h.old_path, h.new_path, h.status, h.fixed_at, h.error_message, b.path FROM history h JOIN books b ON h.book_id = b.id WHERE h.status = 'needs_attention' - ORDER BY h.fixed_at DESC + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9217,9 +9246,11 @@ def api_library(): 'error_message': row['error_message'] }) # Also get books with structure issues or watch folder errors + order2 = build_order_by(BOOK_SORT_COLS, 'current_author, current_title') c.execute('''SELECT id, path, current_author, current_title, status, error_message, source_type FROM books WHERE status IN ('needs_attention', 'structure_reversed', 'watch_folder_error') + ''' + order2 + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9236,10 +9267,11 @@ def api_library(): elif status_filter == 'locked': # User-locked books - books where user has manually set metadata + order = build_order_by(BOOK_SORT_COLS, 'updated_at DESC') c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked FROM books WHERE user_locked = 1 - ORDER BY updated_at DESC + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9255,10 +9287,11 @@ def api_library(): # Issue #53: Media type filters elif status_filter == 'audiobook_only': + order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title') c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type FROM books WHERE media_type = 'audiobook' OR media_type IS NULL - ORDER BY current_author, current_title + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9274,10 +9307,11 @@ def api_library(): }) elif status_filter == 'ebook_only': + order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title') c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type FROM books WHERE media_type = 'ebook' - ORDER BY current_author, current_title + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9293,10 +9327,11 @@ def api_library(): }) elif status_filter == 'both_formats': + order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title') c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type FROM books WHERE media_type = 'both' - ORDER BY current_author, current_title + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): items.append({ @@ -9314,10 +9349,11 @@ def api_library(): elif status_filter == 'search' and search_query: # Search across all books by author or title search_pattern = f'%{search_query}%' + order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title') c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type FROM books WHERE current_author LIKE ? OR current_title LIKE ? - ORDER BY current_author, current_title + ''' + order + ''' LIMIT ? OFFSET ?''', (search_pattern, search_pattern, per_page, offset)) for row in c.fetchall(): items.append({ @@ -9339,12 +9375,13 @@ def api_library(): else: # 'all' - show everything mixed # Get recent history items (includes pending, fixed, errors) + order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC') c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title, h.old_path, h.new_path, h.status, h.fixed_at, h.error_message, b.path, b.current_author, b.current_title, b.user_locked FROM history h JOIN books b ON h.book_id = b.id - ORDER BY h.fixed_at DESC + ''' + order + ''' LIMIT ? OFFSET ?''', (per_page, offset)) for row in c.fetchall(): item_type = 'pending_fix' if row['status'] == 'pending_fix' else \ @@ -9412,6 +9449,8 @@ def api_library(): 'per_page': per_page, 'total': total, 'total_pages': total_pages, + 'sort': sort_by, + 'sort_dir': sort_dir, 'skip_confirmations': config.get('skip_confirmations', False) }) diff --git a/templates/library.html b/templates/library.html index 8bcafd9..120bc24 100644 --- a/templates/library.html +++ b/templates/library.html @@ -73,6 +73,18 @@ padding: 2px 8px; font-size: 0.8rem; } + /* Issue #111: Sortable column headers */ + th[onclick]:hover { + color: #00d9ff; + } + .sort-icon { + font-size: 0.7rem; + opacity: 0.5; + } + .sort-icon.active { + opacity: 1; + color: #00d9ff; + } @@ -206,9 +218,15 @@ - Author - Title - Status + + Author + + + Title + + + Status + Details Actions @@ -348,6 +366,8 @@