From b044b329fcb1e652d6f19a2ac2f24a1220a020e1 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 23 Dec 2025 11:57:25 +0800 Subject: [PATCH] feat: Update recipes page with default descending date sort, refactor state properties for search/filters, and add new localization strings. --- locales/de.json | 14 +- locales/en.json | 12 ++ locales/es.json | 14 +- locales/fr.json | 14 +- locales/he.json | 14 +- locales/ja.json | 14 +- locales/ko.json | 14 +- locales/ru.json | 14 +- locales/zh-CN.json | 14 +- locales/zh-TW.json | 14 +- py/services/recipe_scanner.py | 23 +++- static/js/recipes.js | 1 + static/js/state/index.js | 164 +++++++++++------------ templates/recipes.html | 16 +++ tests/frontend/pages/recipesPage.test.js | 10 +- tests/services/test_recipe_scanner.py | 40 ++++++ 16 files changed, 295 insertions(+), 97 deletions(-) diff --git a/locales/de.json b/locales/de.json index 20590fb5..03e74c70 100644 --- a/locales/de.json +++ b/locales/de.json @@ -589,6 +589,18 @@ "selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus" } }, + "sort": { + "title": "Rezepte sortieren nach...", + "name": "Name", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Datum", + "dateDesc": "Neueste", + "dateAsc": "Älteste", + "lorasCount": "LoRA-Anzahl", + "lorasCountDesc": "Meiste", + "lorasCountAsc": "Wenigste" + }, "refresh": { "title": "Rezeptliste aktualisieren" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 494d3972..13bd170f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -589,6 +589,18 @@ "selectLoraRoot": "Please select a LoRA root directory" } }, + "sort": { + "title": "Sort recipes by...", + "name": "Name", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Date", + "dateDesc": "Newest", + "dateAsc": "Oldest", + "lorasCount": "LoRA Count", + "lorasCountDesc": "Most", + "lorasCountAsc": "Least" + }, "refresh": { "title": "Refresh recipe list" }, diff --git a/locales/es.json b/locales/es.json index 6ba9846e..f5da4abf 100644 --- a/locales/es.json +++ b/locales/es.json @@ -589,6 +589,18 @@ "selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA" } }, + "sort": { + "title": "Ordenar recetas por...", + "name": "Nombre", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Fecha", + "dateDesc": "Más reciente", + "dateAsc": "Más antiguo", + "lorasCount": "Cant. de LoRAs", + "lorasCountDesc": "Más", + "lorasCountAsc": "Menos" + }, "refresh": { "title": "Actualizar lista de recetas" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index 7871346e..54cac2d3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -589,6 +589,18 @@ "selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA" } }, + "sort": { + "title": "Trier les recettes par...", + "name": "Nom", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Date", + "dateDesc": "Plus récent", + "dateAsc": "Plus ancien", + "lorasCount": "Nombre de LoRAs", + "lorasCountDesc": "Plus", + "lorasCountAsc": "Moins" + }, "refresh": { "title": "Actualiser la liste des recipes" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/he.json b/locales/he.json index 37977a96..41402dfc 100644 --- a/locales/he.json +++ b/locales/he.json @@ -589,6 +589,18 @@ "selectLoraRoot": "אנא בחר ספריית שורש של LoRA" } }, + "sort": { + "title": "מיון מתכונים לפי...", + "name": "שם", + "nameAsc": "א - ת", + "nameDesc": "ת - א", + "date": "תאריך", + "dateDesc": "הכי חדש", + "dateAsc": "הכי ישן", + "lorasCount": "מספר LoRAs", + "lorasCountDesc": "הכי הרבה", + "lorasCountAsc": "הכי פחות" + }, "refresh": { "title": "רענן רשימת מתכונים" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/ja.json b/locales/ja.json index da0a812a..8fcd070d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -589,6 +589,18 @@ "selectLoraRoot": "LoRAルートディレクトリを選択してください" } }, + "sort": { + "title": "レシピの並び替え...", + "name": "名前", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "日付", + "dateDesc": "新しい順", + "dateAsc": "古い順", + "lorasCount": "LoRA数", + "lorasCountDesc": "多い順", + "lorasCountAsc": "少ない順" + }, "refresh": { "title": "レシピリストを更新" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/ko.json b/locales/ko.json index cbae5fe9..9cdd36fb 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -589,6 +589,18 @@ "selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요" } }, + "sort": { + "title": "레시피 정렬...", + "name": "이름", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "날짜", + "dateDesc": "최신순", + "dateAsc": "오래된순", + "lorasCount": "LoRA 수", + "lorasCountDesc": "많은순", + "lorasCountAsc": "적은순" + }, "refresh": { "title": "레시피 목록 새로고침" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index edfa486d..58e7ef0e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -589,6 +589,18 @@ "selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA" } }, + "sort": { + "title": "Сортировка рецептов...", + "name": "Имя", + "nameAsc": "А - Я", + "nameDesc": "Я - А", + "date": "Дата", + "dateDesc": "Сначала новые", + "dateAsc": "Сначала старые", + "lorasCount": "Кол-во LoRA", + "lorasCountDesc": "Больше всего", + "lorasCountAsc": "Меньше всего" + }, "refresh": { "title": "Обновить список рецептов" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b4c0f45a..11f5c226 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -589,6 +589,18 @@ "selectLoraRoot": "请选择 LoRA 根目录" } }, + "sort": { + "title": "配方排序...", + "name": "名称", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "时间", + "dateDesc": "最新", + "dateAsc": "最早", + "lorasCount": "LoRA 数量", + "lorasCountDesc": "最多", + "lorasCountAsc": "最少" + }, "refresh": { "title": "刷新配方列表" }, @@ -1485,4 +1497,4 @@ "learnMore": "浏览器插件教程" } } -} +} \ No newline at end of file diff --git a/locales/zh-TW.json b/locales/zh-TW.json index b3b106b2..de95602f 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -589,6 +589,18 @@ "selectLoraRoot": "請選擇 LoRA 根目錄" } }, + "sort": { + "title": "配方排序...", + "name": "名稱", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "時間", + "dateDesc": "最新", + "dateAsc": "最舊", + "lorasCount": "LoRA 數量", + "lorasCountDesc": "最多", + "lorasCountAsc": "最少" + }, "refresh": { "title": "重新整理配方列表" }, @@ -1485,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index a646e97c..c8f1c078 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -1024,7 +1024,14 @@ class RecipeScanner: cache = await self.get_cached_data() # Get base dataset - filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name + sort_field = sort_by.split(':')[0] if ':' in sort_by else sort_by + + if sort_field == 'date': + filtered_data = list(cache.sorted_by_date) + elif sort_field == 'name': + filtered_data = list(cache.sorted_by_name) + else: + filtered_data = list(cache.raw_data) # Apply SFW filtering if enabled from .settings_manager import get_settings_manager @@ -1166,6 +1173,20 @@ class RecipeScanner: if not any(tag in exclude_tags for tag in (item.get('tags', []) or [])) ] + + # Apply sorting if not already handled by pre-sorted cache + if ':' in sort_by or sort_field == 'loras_count': + field, order = (sort_by.split(':') + ['desc'])[:2] + reverse = order.lower() == 'desc' + + if field == 'name': + filtered_data = natsorted(filtered_data, key=lambda x: x.get('title', '').lower(), reverse=reverse) + elif field == 'date': + # Use modified if available, falling back to created_date + filtered_data.sort(key=lambda x: (x.get('modified', x.get('created_date', 0)), x.get('file_path', '')), reverse=reverse) + elif field == 'loras_count': + filtered_data.sort(key=lambda x: len(x.get('loras', [])), reverse=reverse) + # Calculate pagination total_items = len(filtered_data) start_idx = (page - 1) * page_size diff --git a/static/js/recipes.js b/static/js/recipes.js index f74eb583..94f158f7 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -216,6 +216,7 @@ class RecipeManager { // Sort select const sortSelect = document.getElementById('sortSelect'); if (sortSelect) { + sortSelect.value = this.pageState.sortBy || 'date:desc'; sortSelect.addEventListener('change', () => { this.pageState.sortBy = sortSelect.value; refreshVirtualScroll(); diff --git a/static/js/state/index.js b/static/js/state/index.js index 69d05d5b..1dfbf568 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -58,7 +58,7 @@ export const state = { loadingManager: null, observer: null, }, - + // Page-specific states pages: { [MODEL_TYPES.LORA]: { @@ -69,20 +69,20 @@ export const state = { activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`), activeLetterFilter: null, previewVersions: loraPreviewVersions, - searchManager: null, - searchOptions: { - filename: true, - modelname: true, - tags: false, - creator: false, - recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [] - }, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + creator: false, + recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [] + }, bulkMode: false, selectedLoras: new Set(), loraMetadataCache: new Map(), @@ -90,35 +90,35 @@ export const state = { showUpdateAvailableOnly: false, duplicatesMode: false, }, - + recipes: { currentPage: 1, isLoading: false, hasMore: true, - sortBy: 'date', + sortBy: 'date:desc', activeFolder: getStorageItem('recipes_activeFolder'), - searchManager: null, - searchOptions: { - title: true, - tags: true, - loraName: true, - loraModel: true, - recursive: getStorageItem('recipes_recursiveSearch', true), - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [], - search: '' - }, + searchManager: null, + searchOptions: { + title: true, + tags: true, + loraName: true, + loraModel: true, + recursive: getStorageItem('recipes_recursiveSearch', true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [], + search: '' + }, pageSize: 20, showFavoritesOnly: false, duplicatesMode: false, bulkMode: false, selectedModels: new Set(), }, - + [MODEL_TYPES.CHECKPOINT]: { currentPage: 1, isLoading: false, @@ -126,19 +126,19 @@ export const state = { sortBy: 'name', activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`), previewVersions: checkpointPreviewVersions, - searchManager: null, - searchOptions: { - filename: true, - modelname: true, - creator: false, - recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [] - }, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + creator: false, + recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [] + }, modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model' bulkMode: false, selectedModels: new Set(), @@ -147,7 +147,7 @@ export const state = { showUpdateAvailableOnly: false, duplicatesMode: false, }, - + [MODEL_TYPES.EMBEDDING]: { currentPage: 1, isLoading: false, @@ -156,20 +156,20 @@ export const state = { activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`), activeLetterFilter: null, previewVersions: embeddingPreviewVersions, - searchManager: null, - searchOptions: { - filename: true, - modelname: true, - tags: false, - creator: false, - recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [] - }, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + creator: false, + recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [] + }, bulkMode: false, selectedModels: new Set(), metadataCache: new Map(), @@ -178,45 +178,45 @@ export const state = { duplicatesMode: false, } }, - + // Current active page - use MODEL_TYPES constants currentPageType: MODEL_TYPES.LORA, - + // Backward compatibility - proxy properties get currentPage() { return this.pages[this.currentPageType].currentPage; }, set currentPage(value) { this.pages[this.currentPageType].currentPage = value; }, - + get isLoading() { return this.pages[this.currentPageType].isLoading; }, set isLoading(value) { this.pages[this.currentPageType].isLoading = value; }, - + get hasMore() { return this.pages[this.currentPageType].hasMore; }, set hasMore(value) { this.pages[this.currentPageType].hasMore = value; }, - + get sortBy() { return this.pages[this.currentPageType].sortBy; }, set sortBy(value) { this.pages[this.currentPageType].sortBy = value; }, - + get activeFolder() { return this.pages[this.currentPageType].activeFolder; }, set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; }, - + get loadingManager() { return this.global.loadingManager; }, set loadingManager(value) { this.global.loadingManager = value; }, - + get observer() { return this.global.observer; }, set observer(value) { this.global.observer = value; }, - + get previewVersions() { return this.pages.loras.previewVersions; }, set previewVersions(value) { this.pages.loras.previewVersions = value; }, - + get searchManager() { return this.pages[this.currentPageType].searchManager; }, set searchManager(value) { this.pages[this.currentPageType].searchManager = value; }, - + get searchOptions() { return this.pages[this.currentPageType].searchOptions; }, set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; }, - + get filters() { return this.pages[this.currentPageType].filters; }, set filters(value) { this.pages[this.currentPageType].filters = value; }, - - get bulkMode() { + + get bulkMode() { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { return this.pages.loras.bulkMode; @@ -224,7 +224,7 @@ export const state = { return this.pages[currentType].bulkMode; } }, - set bulkMode(value) { + set bulkMode(value) { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { this.pages.loras.bulkMode = value; @@ -232,11 +232,11 @@ export const state = { this.pages[currentType].bulkMode = value; } }, - + get selectedLoras() { return this.pages.loras.selectedLoras; }, set selectedLoras(value) { this.pages.loras.selectedLoras = value; }, - - get selectedModels() { + + get selectedModels() { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { return this.pages.loras.selectedLoras; @@ -244,7 +244,7 @@ export const state = { return this.pages[currentType].selectedModels; } }, - set selectedModels(value) { + set selectedModels(value) { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { this.pages.loras.selectedLoras = value; @@ -252,10 +252,10 @@ export const state = { this.pages[currentType].selectedModels = value; } }, - + get loraMetadataCache() { return this.pages.loras.loraMetadataCache; }, set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; }, - + get settings() { return this.global.settings; }, set settings(value) { this.global.settings = value; } }; diff --git a/templates/recipes.html b/templates/recipes.html index 02223e26..2d7392d2 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -46,6 +46,22 @@
+
+ +
diff --git a/tests/frontend/pages/recipesPage.test.js b/tests/frontend/pages/recipesPage.test.js index fb4a1b1f..07b4706c 100644 --- a/tests/frontend/pages/recipesPage.test.js +++ b/tests/frontend/pages/recipesPage.test.js @@ -100,7 +100,7 @@ describe('RecipeManager', () => { }; pageState = { - sortBy: 'date', + sortBy: 'date:desc', searchOptions: undefined, customFilter: undefined, duplicatesMode: false, @@ -137,8 +137,8 @@ describe('RecipeManager', () => { const sortSelectElement = document.createElement('select'); sortSelectElement.id = 'sortSelect'; sortSelectElement.innerHTML = ` - - + + `; document.body.appendChild(sortSelectElement); @@ -183,10 +183,10 @@ describe('RecipeManager', () => { expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1); const sortSelect = document.getElementById('sortSelect'); - sortSelect.value = 'name'; + sortSelect.value = 'name:asc'; sortSelect.dispatchEvent(new Event('change', { bubbles: true })); - expect(pageState.sortBy).toBe('name'); + expect(pageState.sortBy).toBe('name:asc'); expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2); expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1); }); diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 87afaad2..0fcde964 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -601,3 +601,43 @@ async def test_get_paginated_data_filters_by_prompt(recipe_scanner): page=1, page_size=10, search="forest", search_options={"prompt": False} ) assert len(result_disabled["items"]) == 0 + + +@pytest.mark.asyncio +async def test_get_paginated_data_sorting(recipe_scanner): + scanner, _ = recipe_scanner + + # Add test recipes + # Recipe A: Name "Alpha", Date 10, LoRAs 2 + await scanner.add_recipe({ + "id": "A", "title": "Alpha", "created_date": 10.0, + "loras": [{}, {}], "file_path": "a.png" + }) + # Recipe B: Name "Beta", Date 20, LoRAs 1 + await scanner.add_recipe({ + "id": "B", "title": "Beta", "created_date": 20.0, + "loras": [{}], "file_path": "b.png" + }) + # Recipe C: Name "Gamma", Date 5, LoRAs 3 + await scanner.add_recipe({ + "id": "C", "title": "Gamma", "created_date": 5.0, + "loras": [{}, {}, {}], "file_path": "c.png" + }) + + await asyncio.sleep(0) + + # Test Name DESC: Gamma, Beta, Alpha + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc") + assert [i["id"] for i in res["items"]] == ["C", "B", "A"] + + # Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1) + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc") + assert [i["id"] for i in res["items"]] == ["C", "A", "B"] + + # Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3) + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc") + assert [i["id"] for i in res["items"]] == ["B", "A", "C"] + + # Test Date ASC: Gamma (5), Alpha (10), Beta (20) + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc") + assert [i["id"] for i in res["items"]] == ["C", "A", "B"]