From b10bcf7e78df42caa5cddf044a43a068f12fa882 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 29 Oct 2025 07:32:53 +0800 Subject: [PATCH] feat: add update availability filter to model list Add a new filter option to show only models with available updates across all supported languages. This includes: - Adding "updates" filter translations in all locale files (de, en, es, fr, he, ja, ko) - Extending BaseModelApiClient to handle update_available_only query parameter - Implementing update filter button in PageControls component with event listeners - Adding corresponding CSS styles for active filter state The feature allows users to quickly identify and focus on models that have updates available, improving the update management workflow. --- locales/de.json | 4 + locales/en.json | 4 + locales/es.json | 4 + locales/fr.json | 4 + locales/he.json | 4 + locales/ja.json | 4 + locales/ko.json | 4 + locales/ru.json | 4 + locales/zh-CN.json | 4 + locales/zh-TW.json | 4 + py/routes/handlers/model_handlers.py | 7 +- py/services/base_model_service.py | 99 ++++--------------- static/css/layout.css | 17 +++- static/js/api/baseModelApi.js | 6 +- static/js/components/controls/PageControls.js | 55 +++++++++-- static/js/managers/FilterManager.js | 6 +- static/js/state/index.js | 3 + templates/components/controls.html | 5 + .../components/pageControls.filtering.test.js | 81 +++++++++++++++ tests/services/test_base_model_service.py | 79 ++++++++++++++- 20 files changed, 300 insertions(+), 98 deletions(-) diff --git a/locales/de.json b/locales/de.json index 725339c1..92b97d62 100644 --- a/locales/de.json +++ b/locales/de.json @@ -432,6 +432,10 @@ "favorites": { "title": "Nur Favoriten anzeigen", "action": "Favoriten" + }, + "updates": { + "title": "Nur Modelle mit verfügbaren Updates anzeigen", + "action": "Updates" } }, "bulkOperations": { diff --git a/locales/en.json b/locales/en.json index a22e4d57..553159ee 100644 --- a/locales/en.json +++ b/locales/en.json @@ -431,6 +431,10 @@ "favorites": { "title": "Show Favorites Only", "action": "Favorites" + }, + "updates": { + "title": "Show models with updates available", + "action": "Updates" } }, "bulkOperations": { diff --git a/locales/es.json b/locales/es.json index 54edf26a..9b1da2ba 100644 --- a/locales/es.json +++ b/locales/es.json @@ -431,6 +431,10 @@ "favorites": { "title": "Mostrar solo favoritos", "action": "Favoritos" + }, + "updates": { + "title": "Mostrar solo modelos con actualizaciones disponibles", + "action": "Actualizaciones" } }, "bulkOperations": { diff --git a/locales/fr.json b/locales/fr.json index 17b6e098..aa05fb79 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -431,6 +431,10 @@ "favorites": { "title": "Afficher uniquement les favoris", "action": "Favoris" + }, + "updates": { + "title": "Afficher uniquement les modèles avec des mises à jour disponibles", + "action": "Mises à jour" } }, "bulkOperations": { diff --git a/locales/he.json b/locales/he.json index 64fbcc49..c7627198 100644 --- a/locales/he.json +++ b/locales/he.json @@ -431,6 +431,10 @@ "favorites": { "title": "הצג מועדפים בלבד", "action": "מועדפים" + }, + "updates": { + "title": "הצג רק דגמים עם עדכונים זמינים", + "action": "עדכונים" } }, "bulkOperations": { diff --git a/locales/ja.json b/locales/ja.json index bb37ac04..39c5aef2 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -431,6 +431,10 @@ "favorites": { "title": "お気に入りのみ表示", "action": "お気に入り" + }, + "updates": { + "title": "アップデート可能なモデルのみ表示", + "action": "アップデート" } }, "bulkOperations": { diff --git a/locales/ko.json b/locales/ko.json index 45cb597f..df61ecc4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -431,6 +431,10 @@ "favorites": { "title": "즐겨찾기만 보기", "action": "즐겨찾기" + }, + "updates": { + "title": "업데이트 가능한 모델만 표시", + "action": "업데이트" } }, "bulkOperations": { diff --git a/locales/ru.json b/locales/ru.json index 5ef3eca6..189225bb 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -431,6 +431,10 @@ "favorites": { "title": "Показать только избранное", "action": "Избранное" + }, + "updates": { + "title": "Показывать только модели с доступными обновлениями", + "action": "Обновления" } }, "bulkOperations": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index fe3e9f41..fa2e63c4 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -431,6 +431,10 @@ "favorites": { "title": "仅显示收藏", "action": "收藏" + }, + "updates": { + "title": "仅显示可用更新的模型", + "action": "更新" } }, "bulkOperations": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d05aac31..586a5dbe 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -431,6 +431,10 @@ "favorites": { "title": "僅顯示收藏", "action": "收藏" + }, + "updates": { + "title": "僅顯示可用更新的模型", + "action": "更新" } }, "bulkOperations": { diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index f95968ed..2894c44f 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -166,10 +166,7 @@ class ModelListingHandler: except (json.JSONDecodeError, TypeError): pass - has_update = request.query.get("has_update", "false") - has_update_filter = ( - has_update.lower() in {"1", "true", "yes"} if isinstance(has_update, str) else False - ) + update_available_only = request.query.get("update_available_only", "false").lower() == "true" return { "page": page, @@ -183,7 +180,7 @@ class ModelListingHandler: "search_options": search_options, "hash_filters": hash_filters, "favorites_only": favorites_only, - "has_update": has_update_filter, + "update_available_only": update_available_only, **self._parse_specific_params(request), } diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index b7f4f40b..dee143aa 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -63,10 +63,11 @@ class BaseModelService(ABC): search_options: dict = None, hash_filters: dict = None, favorites_only: bool = False, - has_update: bool = False, + update_available_only: bool = False, **kwargs, ) -> Dict: """Get paginated and filtered model data""" + sort_params = self.cache_repository.parse_sort(sort_by) sorted_data = await self.cache_repository.fetch_sorted(sort_params) @@ -92,14 +93,23 @@ class BaseModelService(ABC): filtered_data = await self._apply_specific_filters(filtered_data, **kwargs) - if has_update: - filtered_data = await self._apply_update_filter(filtered_data) + annotated_for_filter: Optional[List[Dict]] = None + if update_available_only: + annotated_for_filter = await self._annotate_update_flags(filtered_data) + filtered_data = [ + item for item in annotated_for_filter + if item.get('update_available') + ] paginated = self._paginate(filtered_data, page, page_size) - paginated['items'] = await self._annotate_update_flags( - paginated['items'], - assume_true=has_update, - ) + + if update_available_only: + # Items already include update flags thanks to the pre-filter annotation. + paginated['items'] = list(paginated['items']) + else: + paginated['items'] = await self._annotate_update_flags( + paginated['items'], + ) return paginated @@ -160,92 +170,19 @@ class BaseModelService(ABC): """Apply model-specific filters - to be overridden by subclasses if needed""" return data - async def _apply_update_filter(self, data: List[Dict]) -> List[Dict]: - """Filter models to those with remote updates available when requested.""" - if not data: - return [] - if self.update_service is None: - logger.warning( - "Requested has_update filter for %s models but update service is unavailable", - self.model_type, - ) - return [] - - id_to_items: Dict[int, List[Dict]] = {} - ordered_ids: List[int] = [] - for item in data: - model_id = self._extract_model_id(item) - if model_id is None: - continue - if model_id not in id_to_items: - id_to_items[model_id] = [] - ordered_ids.append(model_id) - id_to_items[model_id].append(item) - - if not ordered_ids: - return [] - - resolved: Optional[Dict[int, bool]] = None - bulk_method = getattr(self.update_service, "has_updates_bulk", None) - if callable(bulk_method): - try: - resolved = await bulk_method(self.model_type, ordered_ids) - except Exception as exc: - logger.error( - "Failed to resolve update status in bulk for %s models (%s): %s", - self.model_type, - ordered_ids, - exc, - exc_info=True, - ) - resolved = None - - if resolved is None: - tasks = [ - self.update_service.has_update(self.model_type, model_id) - for model_id in ordered_ids - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - resolved = {} - for model_id, result in zip(ordered_ids, results): - if isinstance(result, Exception): - logger.error( - "Failed to resolve update status for model %s (%s): %s", - model_id, - self.model_type, - result, - ) - continue - resolved[model_id] = bool(result) - - filtered: List[Dict] = [] - for item in data: - model_id = self._extract_model_id(item) - if model_id is not None and resolved.get(model_id, False): - filtered.append(item) - return filtered - async def _annotate_update_flags( self, items: List[Dict], - *, - assume_true: bool = False, ) -> List[Dict]: """Attach an update_available flag to each response item. - Items without a civitai model id default to False. When the caller already - filtered for updates we can skip the lookup and mark everything True. + Items without a civitai model id default to False. """ if not items: return [] annotated = [dict(item) for item in items] - if assume_true: - for item in annotated: - item['update_available'] = True - return annotated - if self.update_service is None: for item in annotated: item['update_available'] = False diff --git a/static/css/layout.css b/static/css/layout.css index a2f84344..5f862b00 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -105,7 +105,8 @@ } /* Controls */ -.control-group button.favorite-filter { +.control-group button.favorite-filter, +.control-group button.update-filter { position: relative; overflow: hidden; } @@ -120,6 +121,20 @@ color: #ffc107; } +.control-group button.update-filter i { + margin-right: 4px; + color: var(--lora-accent); +} + +.control-group button.update-filter.active { + background: var(--lora-accent); + color: white; +} + +.control-group button.update-filter.active i { + color: white; +} + /* Active state for buttons that can be toggled */ .control-group button.active { background: var(--lora-accent); diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 8c6af107..52b65400 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -749,7 +749,11 @@ export class BaseModelApiClient { if (pageState.showFavoritesOnly) { params.append('favorites_only', 'true'); } - + + if (pageState.showUpdateAvailableOnly) { + params.append('update_available_only', 'true'); + } + if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { params.append('first_letter', pageState.activeLetterFilter); } diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 6e9766f2..b3b8b9a3 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -30,6 +30,9 @@ export class PageControls { // Initialize event listeners this.initEventListeners(); + // Initialize update availability filter button state + this.initUpdateAvailableFilter(); + // Initialize favorites filter button state this.initFavoritesFilter(); @@ -189,12 +192,17 @@ export class PageControls { if (bulkButton) { bulkButton.addEventListener('click', () => this.toggleBulkMode()); } - + // Favorites filter button handler const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); if (favoriteFilterBtn) { favoriteFilterBtn.addEventListener('click', () => this.toggleFavoritesOnly()); } + + const updateFilterBtn = document.getElementById('updateFilterBtn'); + if (updateFilterBtn) { + updateFilterBtn.addEventListener('click', () => this.toggleUpdateAvailableOnly()); + } } /** @@ -378,17 +386,33 @@ export class PageControls { // Get current state from session storage with page-specific key const storageKey = `show_favorites_only_${this.pageType}`; const showFavoritesOnly = getSessionItem(storageKey, false); - + // Update button state if (showFavoritesOnly) { favoriteFilterBtn.classList.add('active'); } - + // Update app state this.pageState.showFavoritesOnly = showFavoritesOnly; } } - + + /** + * Initialize update availability filter button state + */ + initUpdateAvailableFilter() { + const storageKey = `show_update_available_only_${this.pageType}`; + const storedValue = getSessionItem(storageKey, false); + const showUpdatesOnly = storedValue === true || storedValue === 'true'; + + this.pageState.showUpdateAvailableOnly = showUpdatesOnly; + + const updateFilterBtn = document.getElementById('updateFilterBtn'); + if (updateFilterBtn) { + updateFilterBtn.classList.toggle('active', showUpdatesOnly); + } + } + /** * Toggle favorites-only filter and reload models */ @@ -410,10 +434,29 @@ export class PageControls { if (favoriteFilterBtn) { favoriteFilterBtn.classList.toggle('active', newState); } - + // Reload models with new filter await this.resetAndReload(true); } + + /** + * Toggle update-available-only filter and reload models + */ + async toggleUpdateAvailableOnly() { + const updateFilterBtn = document.getElementById('updateFilterBtn'); + const storageKey = `show_update_available_only_${this.pageType}`; + const newState = !this.pageState.showUpdateAvailableOnly; + + setSessionItem(storageKey, newState); + + this.pageState.showUpdateAvailableOnly = newState; + + if (updateFilterBtn) { + updateFilterBtn.classList.toggle('active', newState); + } + + await this.resetAndReload(true); + } /** * Find duplicate models @@ -437,4 +480,4 @@ export class PageControls { this.sidebarManager.cleanup(); } } -} \ No newline at end of file +} diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index adac4ede..2a05d52b 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -312,7 +312,11 @@ export class FilterManager { removeStorageItem(storageKey); // Update UI - this.filterButton.classList.remove('active'); + if (this.hasActiveFilters()) { + this.filterButton.classList.add('active'); + } else { + this.filterButton.classList.remove('active'); + } // Reload data using the appropriate method for the current page if (this.currentPage === 'recipes' && window.recipeManager) { diff --git a/static/js/state/index.js b/static/js/state/index.js index 5088011d..9214b9d8 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -81,6 +81,7 @@ export const state = { selectedLoras: new Set(), loraMetadataCache: new Map(), showFavoritesOnly: false, + showUpdateAvailableOnly: false, duplicatesMode: false, }, @@ -131,6 +132,7 @@ export const state = { selectedModels: new Set(), metadataCache: new Map(), showFavoritesOnly: false, + showUpdateAvailableOnly: false, duplicatesMode: false, }, @@ -158,6 +160,7 @@ export const state = { selectedModels: new Set(), metadataCache: new Map(), showFavoritesOnly: false, + showUpdateAvailableOnly: false, duplicatesMode: false, } }, diff --git a/templates/components/controls.html b/templates/components/controls.html index 8c9f5af5..0d0ac6b6 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -56,6 +56,11 @@ {{ t('loras.controls.favorites.action') }} +