From 8cae94d87dc9d6a998583d070d64ab287478ffbb Mon Sep 17 00:00:00 2001 From: erik Date: Wed, 28 Jan 2026 15:32:54 +0000 Subject: [PATCH] Add client-side spell column sorting and improve inventory search - Implement client-side sorting for all columns including spell_names - Add computed_spell_names CTE for server-side sort fallback - Add resizable columns with localStorage persistence - Add Cloak slot detection by name pattern - Increase items limit to 50000 for full inventory loading - Increase proxy timeout to 60s for large queries - Remove pagination (all items loaded at once for sorting) Co-Authored-By: Claude Opus 4.5 --- .../comprehensive_enum_database_v2.json | 109 ++++++++- inventory-service/database.py | 2 +- inventory-service/main.py | 43 +++- main.py | 4 +- static/inventory.html | 33 +++ static/inventory.js | 214 +++++++++++------- 6 files changed, 294 insertions(+), 111 deletions(-) diff --git a/inventory-service/comprehensive_enum_database_v2.json b/inventory-service/comprehensive_enum_database_v2.json index fd7ec101..2afa8a16 100644 --- a/inventory-service/comprehensive_enum_database_v2.json +++ b/inventory-service/comprehensive_enum_database_v2.json @@ -2188,7 +2188,7 @@ "dictionaries": { "AttributeSetInfo": { "name": "AttributeSetInfo", - "description": "Equipment set names", + "description": "Equipment set names (complete from Dictionaries.cs)", "values": { "2": "Test", "4": "Carraida's Benediction", @@ -2210,7 +2210,7 @@ "20": "Dexterous Set", "21": "Wise Set", "22": "Swift Set", - "23": "Hardenend Set", + "23": "Hardened Set", "24": "Reinforced Set", "25": "Interlocking Set", "26": "Flame Proof Set", @@ -2232,15 +2232,102 @@ "42": "Olthoi Armor D Red", "43": "Olthoi Armor C Rat", "44": "Olthoi Armor C Red", - "45": "Olthoi Armor F Red", - "46": "Olthoi Armor K Red", - "47": "Olthoi Armor M Red", - "48": "Olthoi Armor B Red", - "49": "Olthoi Armor B Rat", - "50": "Olthoi Armor K Rat", - "51": "Olthoi Armor M Rat", - "52": "Olthoi Armor F Rat", - "53": "Olthoi Armor D Rat" + "45": "Olthoi Armor D Rat", + "46": "Upgraded Relic Alduressa Set", + "47": "Upgraded Ancient Relic Set", + "48": "Upgraded Noble Relic Set", + "49": "Weave of Alchemy", + "50": "Weave of Arcane Lore", + "51": "Weave of Armor Tinkering", + "52": "Weave of Assess Person", + "53": "Weave of Light Weapons", + "54": "Weave of Missile Weapons", + "55": "Weave of Cooking", + "56": "Weave of Creature Enchantment", + "57": "Weave of Missile Weapons", + "58": "Weave of Finesse", + "59": "Weave of Deception", + "60": "Weave of Fletching", + "61": "Weave of Healing", + "62": "Weave of Item Enchantment", + "63": "Weave of Item Tinkering", + "64": "Weave of Leadership", + "65": "Weave of Life Magic", + "66": "Weave of Loyalty", + "67": "Weave of Light Weapons", + "68": "Weave of Magic Defense", + "69": "Weave of Magic Item Tinkering", + "70": "Weave of Mana Conversion", + "71": "Weave of Melee Defense", + "72": "Weave of Missile Defense", + "73": "Weave of Salvaging", + "74": "Weave of Light Weapons", + "75": "Weave of Light Weapons", + "76": "Weave of Heavy Weapons", + "77": "Weave of Missile Weapons", + "78": "Weave of Two Handed Combat", + "79": "Weave of Light Weapons", + "80": "Weave of Void Magic", + "81": "Weave of War Magic", + "82": "Weave of Weapon Tinkering", + "83": "Weave of Assess Creature", + "84": "Weave of Dirty Fighting", + "85": "Weave of Dual Wield", + "86": "Weave of Recklessness", + "87": "Weave of Shield", + "88": "Weave of Sneak Attack", + "89": "Ninja_New", + "90": "Weave of Summoning", + "91": "Shrouded Soul", + "92": "Darkened Mind", + "93": "Clouded Spirit", + "94": "Minor Stinging Shrouded Soul", + "95": "Minor Sparking Shrouded Soul", + "96": "Minor Smoldering Shrouded Soul", + "97": "Minor Shivering Shrouded Soul", + "98": "Minor Stinging Darkened Mind", + "99": "Minor Sparking Darkened Mind", + "100": "Minor Smoldering Darkened Mind", + "101": "Minor Shivering Darkened Mind", + "102": "Minor Stinging Clouded Spirit", + "103": "Minor Sparking Clouded Spirit", + "104": "Minor Smoldering Clouded Spirit", + "105": "Minor Shivering Clouded Spirit", + "106": "Major Stinging Shrouded Soul", + "107": "Major Sparking Shrouded Soul", + "108": "Major Smoldering Shrouded Soul", + "109": "Major Shivering Shrouded Soul", + "110": "Major Stinging Darkened Mind", + "111": "Major Sparking Darkened Mind", + "112": "Major Smoldering Darkened Mind", + "113": "Major Shivering Darkened Mind", + "114": "Major Stinging Clouded Spirit", + "115": "Major Sparking Clouded Spirit", + "116": "Major Smoldering Clouded Spirit", + "117": "Major Shivering Clouded Spirit", + "118": "Blackfire Stinging Shrouded Soul", + "119": "Blackfire Sparking Shrouded Soul", + "120": "Blackfire Smoldering Shrouded Soul", + "121": "Blackfire Shivering Shrouded Soul", + "122": "Blackfire Stinging Darkened Mind", + "123": "Blackfire Sparking Darkened Mind", + "124": "Blackfire Smoldering Darkened Mind", + "125": "Blackfire Shivering Darkened Mind", + "126": "Blackfire Stinging Clouded Spirit", + "127": "Blackfire Sparking Clouded Spirit", + "128": "Blackfire Smoldering Clouded Spirit", + "129": "Blackfire Shivering Clouded Spirit", + "130": "Shimmering Shadows", + "131": "Brown Society Locket", + "132": "Yellow Society Locket", + "133": "Red Society Band", + "134": "Green Society Band", + "135": "Purple Society Band", + "136": "Blue Society Band", + "137": "Gauntlet Garb", + "138": "Paragon Missile Weapons", + "139": "Paragon Casters", + "140": "Paragon Melee Weapons" } } }, diff --git a/inventory-service/database.py b/inventory-service/database.py index 64f3c457..d1c0a502 100644 --- a/inventory-service/database.py +++ b/inventory-service/database.py @@ -232,7 +232,7 @@ class ItemRatings(Base): class ItemSpells(Base): """Spell information for items.""" __tablename__ = 'item_spells' - + item_id = Column(Integer, ForeignKey('items.id'), primary_key=True) spell_id = Column(Integer, primary_key=True) is_active = Column(Boolean, default=False) diff --git a/inventory-service/main.py b/inventory-service/main.py index ecf300be..3512f6d8 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -2085,7 +2085,7 @@ async def search_items( sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level"), sort_dir: str = Query("asc", description="Sort direction: asc or desc"), page: int = Query(1, ge=1, description="Page number"), - limit: int = Query(200, ge=1, le=10000, description="Items per page") + limit: int = Query(200, ge=1, le=50000, description="Items per page") ): """ Search items across characters with comprehensive filtering options. @@ -2232,14 +2232,24 @@ async def search_items( WHEN i.object_class = 6 THEN 'Melee Weapon' WHEN i.object_class = 7 THEN 'Missile Weapon' WHEN i.object_class = 8 THEN 'Held' - + -- Check wielded location for two-handed weapons WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed' - + + -- CLOAKS: Identify by name pattern + WHEN i.name ILIKE '%cloak%' THEN 'Cloak' + -- DEFAULT ELSE '-' - END as computed_slot_name - + END as computed_slot_name, + + -- Compute spell_names (spell IDs for client-side name lookup) + COALESCE( + (SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id) + FROM item_spells sp_inner WHERE sp_inner.item_id = i.id), + '' + ) as computed_spell_names + FROM items i LEFT JOIN item_combat_stats cs ON i.id = cs.item_id LEFT JOIN item_requirements req ON i.id = req.item_id @@ -2495,7 +2505,11 @@ async def search_items( slot_approaches.append("(object_class = 11 AND name ILIKE '%trinket%')") # 4. Jewelry fallback: items that don't match other jewelry patterns slot_approaches.append("(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')") - + elif slot_name.lower() == 'cloak': + # For cloaks: identify by name pattern + slot_approaches.append("(name ILIKE '%cloak%')") + slot_approaches.append("(computed_slot_name = 'Cloak')") + # Combine approaches with OR (any approach can match) if slot_approaches: slot_conditions.append(f"({' OR '.join(slot_approaches)})") @@ -2663,9 +2677,11 @@ async def search_items( # Add ORDER BY sort_mapping = { "name": "name", + "character_name": "character_name", "value": "value", "damage": "max_damage", - "armor": "armor_level", + "armor": "armor_level", + "armor_level": "armor_level", "workmanship": "workmanship", "level": "wield_level", "damage_rating": "damage_rating", @@ -2673,11 +2689,20 @@ async def search_items( "heal_boost_rating": "heal_boost_rating", "vitality_rating": "vitality_rating", "damage_resist_rating": "damage_resist_rating", - "crit_damage_resist_rating": "crit_damage_resist_rating" + "crit_damage_resist_rating": "crit_damage_resist_rating", + "item_set": "item_set", + "slot_name": "computed_slot_name", + "coverage": "coverage_mask", + "item_type_name": "object_class", + "last_updated": "timestamp", + "spell_names": "computed_spell_names" } sort_field = sort_mapping.get(sort_by, "name") sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC" - query_parts.append(f"ORDER BY {sort_field} {sort_direction}") + + # Handle NULLS for optional fields + nulls_clause = "NULLS LAST" if sort_direction == "ASC" else "NULLS FIRST" + query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}") # Add pagination offset = (page - 1) * limit diff --git a/main.py b/main.py index 90db523f..d2821afd 100644 --- a/main.py +++ b/main.py @@ -2275,8 +2275,8 @@ async def proxy_inventory_service(path: str, request: Request): inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000') logger.info(f"Proxying to inventory service: {inventory_service_url}/{path}") - # Forward the request to inventory service - async with httpx.AsyncClient() as client: + # Forward the request to inventory service (60s timeout for large queries) + async with httpx.AsyncClient(timeout=60.0) as client: response = await client.request( method=request.method, url=f"{inventory_service_url}/{path}", diff --git a/static/inventory.html b/static/inventory.html index d96f7c0e..ad23112e 100644 --- a/static/inventory.html +++ b/static/inventory.html @@ -265,6 +265,7 @@ border-collapse: collapse; font-size: 10px; color: #000; + table-layout: fixed; } .results-table th { @@ -276,11 +277,36 @@ position: sticky; top: 0; font-size: 9px; + position: relative; + user-select: none; + } + + /* Resizable column handle */ + .results-table th .resize-handle { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 5px; + cursor: col-resize; + background: transparent; + } + + .results-table th .resize-handle:hover, + .results-table th .resize-handle.resizing { + background: #666; + } + + .results-table th.resizing { + background: #b0b0b0; } .results-table td { padding: 1px 3px; border: 1px solid #ddd; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .results-table tbody tr:hover { @@ -542,6 +568,9 @@ + + + @@ -944,6 +973,10 @@ +
+ + +
diff --git a/static/inventory.js b/static/inventory.js index e77978fa..288b84b3 100644 --- a/static/inventory.js +++ b/static/inventory.js @@ -20,10 +20,9 @@ let currentSort = { // Store current search results for client-side sorting let currentResultsData = null; -// Pagination state +// Items limit - load all items for client-side sorting (backend max is 50000) let currentPage = 1; -let itemsPerPage = 5000; // 5k items per page for good performance -let totalPages = 1; +let itemsPerPage = 50000; // Initialize the application document.addEventListener('DOMContentLoaded', function() { @@ -44,7 +43,7 @@ function initializeEventListeners() { // Form submission searchForm.addEventListener('submit', async (e) => { e.preventDefault(); - await performSearch(true); // Reset to page 1 on new search + await performSearch(); }); // Clear button @@ -168,14 +167,13 @@ function clearAllFields() { // Reset equipment type to armor document.getElementById('armorOnly').checked = true; - + // Reset slot filter document.getElementById('slotFilter').value = ''; - - // Reset pagination + + // Reset page currentPage = 1; - totalPages = 1; - + // Reset results and clear stored data currentResultsData = null; searchResults.innerHTML = '
Enter search criteria above and click "Search Items" to find inventory items.
'; @@ -205,12 +203,8 @@ function handleSlotFilterChange() { /** * Perform the search based on form inputs */ -async function performSearch(resetPage = false) { - // Reset to page 1 if this is a new search (not pagination) - if (resetPage) { - currentPage = 1; - } - +async function performSearch() { + currentPage = 1; searchResults.innerHTML = '
🔍 Searching inventory...
'; try { @@ -227,10 +221,7 @@ async function performSearch(resetPage = false) { // Store results for client-side re-sorting currentResultsData = data; - - // Update pagination state - updatePaginationState(data); - + // Apply client-side slot filtering applySlotFilter(data); @@ -396,12 +387,8 @@ function getSelectedProtections() { */ function getSelectedSlots() { const selectedSlots = []; - // Get armor slots - document.querySelectorAll('#armor-slots input[type="checkbox"]:checked').forEach(cb => { - selectedSlots.push(cb.value); - }); - // Get jewelry slots - document.querySelectorAll('#jewelry-slots input[type="checkbox"]:checked').forEach(cb => { + // Get all selected slot checkboxes from the all-slots container + document.querySelectorAll('#all-slots input[type="checkbox"]:checked').forEach(cb => { selectedSlots.push(cb.value); }); return selectedSlots; @@ -505,12 +492,16 @@ function displayResults(data) { // Get item type for display const itemType = item.item_type_name || '-'; - // Format equipment set name + // Format equipment set name - prefer translated name let setDisplay = '-'; - if (item.item_set) { - // Remove redundant "Set" prefix if present + if (item.item_set_name) { + // Use the translated set name from backend + setDisplay = item.item_set_name; + // Remove redundant "Set" suffix if present for cleaner display + setDisplay = setDisplay.replace(/\s+Set$/i, ''); + } else if (item.item_set) { + // Fallback to raw set ID if name not available setDisplay = item.item_set.replace(/^Set\s+/i, ''); - // Also handle if it ends with " Set" setDisplay = setDisplay.replace(/\s+Set$/i, ''); } @@ -540,24 +531,8 @@ function displayResults(data) { `; - // Add pagination controls if needed - if (totalPages > 1) { - const isFirstPage = currentPage === 1; - const isLastPage = currentPage === totalPages; - - html += ` -
- - - Page ${currentPage} of ${totalPages} (${data.total_count} total items) - - -
- `; - } - searchResults.innerHTML = html; - + // Add click event listeners to sortable headers document.querySelectorAll('.sortable').forEach(header => { header.addEventListener('click', () => { @@ -565,6 +540,98 @@ function displayResults(data) { handleSort(sortField); }); }); + + // Initialize resizable columns + initResizableColumns(); +} + +/** + * Initialize resizable columns for the results table + */ +function initResizableColumns() { + const table = document.querySelector('.results-table'); + if (!table) return; + + const headers = table.querySelectorAll('th'); + + headers.forEach((header, index) => { + // Skip last column (no need to resize) + if (index === headers.length - 1) return; + + // Create resize handle + const resizeHandle = document.createElement('div'); + resizeHandle.className = 'resize-handle'; + header.appendChild(resizeHandle); + + let startX, startWidth; + + resizeHandle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); // Prevent sorting when resizing + + startX = e.pageX; + startWidth = header.offsetWidth; + + header.classList.add('resizing'); + resizeHandle.classList.add('resizing'); + + const onMouseMove = (e) => { + const diff = e.pageX - startX; + const newWidth = Math.max(30, startWidth + diff); // Minimum 30px width + header.style.width = newWidth + 'px'; + header.style.minWidth = newWidth + 'px'; + }; + + const onMouseUp = () => { + header.classList.remove('resizing'); + resizeHandle.classList.remove('resizing'); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + + // Save column widths to localStorage + saveColumnWidths(); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + }); + + // Restore saved column widths + restoreColumnWidths(); +} + +/** + * Save column widths to localStorage + */ +function saveColumnWidths() { + const headers = document.querySelectorAll('.results-table th'); + const widths = []; + headers.forEach(header => { + widths.push(header.style.width || ''); + }); + localStorage.setItem('inventoryColumnWidths', JSON.stringify(widths)); +} + +/** + * Restore column widths from localStorage + */ +function restoreColumnWidths() { + const saved = localStorage.getItem('inventoryColumnWidths'); + if (!saved) return; + + try { + const widths = JSON.parse(saved); + const headers = document.querySelectorAll('.results-table th'); + headers.forEach((header, index) => { + if (widths[index]) { + header.style.width = widths[index]; + header.style.minWidth = widths[index]; + } + }); + } catch (e) { + console.error('Failed to restore column widths:', e); + } } /** * Apply client-side slot filtering @@ -636,7 +703,7 @@ function sortResults(data) { } /** - * Handle column sorting + * Handle column sorting - client-side sorting for all columns */ function handleSort(field) { // If clicking the same field, toggle direction @@ -647,10 +714,17 @@ function handleSort(field) { currentSort.field = field; currentSort.direction = 'asc'; } - - // Reset to page 1 and perform new search with updated sort - currentPage = 1; - performSearch(); + + // If we have cached results, sort them client-side and re-display + if (currentResultsData && currentResultsData.items) { + // Make a copy to preserve original order + const sortedData = JSON.parse(JSON.stringify(currentResultsData)); + sortResults(sortedData); + displayResults(sortedData); + } else { + // No cached results, need to fetch first + performSearch(); + } } /** @@ -794,40 +868,4 @@ function displaySetAnalysisResults(data) { setAnalysisResults.innerHTML = html; } -/** - * Update pagination state from API response - */ -function updatePaginationState(data) { - totalPages = data.total_pages || 1; - // Current page is already tracked in currentPage -} - -/** - * Go to a specific page - */ -function goToPage(page) { - if (page < 1 || page > totalPages || page === currentPage) { - return; - } - - currentPage = page; - performSearch(); -} - -/** - * Go to next page - */ -function nextPage() { - if (currentPage < totalPages) { - goToPage(currentPage + 1); - } -} - -/** - * Go to previous page - */ -function previousPage() { - if (currentPage > 1) { - goToPage(currentPage - 1); - } -} +// Pagination functions removed - using client-side sorting with all results loaded