diff --git a/locales/de.json b/locales/de.json index 139d6808..b6ba9b2a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -14,7 +14,8 @@ "backToTop": "Nach oben", "settings": "Einstellungen", "help": "Hilfe", - "add": "Hinzufügen" + "add": "Hinzufügen", + "close": "Schließen" }, "status": { "loading": "Wird geladen...", diff --git a/locales/en.json b/locales/en.json index 0127c7fa..4504011b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -14,7 +14,8 @@ "backToTop": "Back to top", "settings": "Settings", "help": "Help", - "add": "Add" + "add": "Add", + "close": "Close" }, "status": { "loading": "Loading...", diff --git a/locales/es.json b/locales/es.json index 55872266..f57dfea4 100644 --- a/locales/es.json +++ b/locales/es.json @@ -14,7 +14,8 @@ "backToTop": "Volver arriba", "settings": "Configuración", "help": "Ayuda", - "add": "Añadir" + "add": "Añadir", + "close": "Cerrar" }, "status": { "loading": "Cargando...", diff --git a/locales/fr.json b/locales/fr.json index f8909a99..d9231b56 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -14,7 +14,8 @@ "backToTop": "Retour en haut", "settings": "Paramètres", "help": "Aide", - "add": "Ajouter" + "add": "Ajouter", + "close": "Fermer" }, "status": { "loading": "Chargement...", diff --git a/locales/he.json b/locales/he.json index 1a91e49b..dfe9ede9 100644 --- a/locales/he.json +++ b/locales/he.json @@ -14,7 +14,8 @@ "backToTop": "חזרה למעלה", "settings": "הגדרות", "help": "עזרה", - "add": "הוספה" + "add": "הוספה", + "close": "סגור" }, "status": { "loading": "טוען...", diff --git a/locales/ja.json b/locales/ja.json index a3bd009c..809c027e 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -14,7 +14,8 @@ "backToTop": "トップへ戻る", "settings": "設定", "help": "ヘルプ", - "add": "追加" + "add": "追加", + "close": "閉じる" }, "status": { "loading": "読み込み中...", diff --git a/locales/ko.json b/locales/ko.json index 251c200e..4152d93a 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -14,7 +14,8 @@ "backToTop": "맨 위로", "settings": "설정", "help": "도움말", - "add": "추가" + "add": "추가", + "close": "닫기" }, "status": { "loading": "로딩 중...", diff --git a/locales/ru.json b/locales/ru.json index 6dea34db..1856fa99 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -14,7 +14,8 @@ "backToTop": "Наверх", "settings": "Настройки", "help": "Справка", - "add": "Добавить" + "add": "Добавить", + "close": "Закрыть" }, "status": { "loading": "Загрузка...", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 5d49180b..50243c89 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -14,7 +14,8 @@ "backToTop": "返回顶部", "settings": "设置", "help": "帮助", - "add": "添加" + "add": "添加", + "close": "关闭" }, "status": { "loading": "加载中...", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index e9607271..a29a8d81 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -14,7 +14,8 @@ "backToTop": "回到頂部", "settings": "設定", "help": "說明", - "add": "新增" + "add": "新增", + "close": "關閉" }, "status": { "loading": "載入中...", diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index c08e0dad..681ed325 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -208,7 +208,11 @@ class BaseModelService(ABC): reverse = sort_params.order == "desc" annotated.sort( - key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()), + key=lambda x: ( + x.get("usage_count", 0), + x.get("model_name", "").lower(), + x.get("file_path", "").lower() + ), reverse=reverse, ) return annotated diff --git a/py/services/lora_service.py b/py/services/lora_service.py index 6b22209c..740a6d22 100644 --- a/py/services/lora_service.py +++ b/py/services/lora_service.py @@ -516,12 +516,18 @@ class LoraService(BaseModelService): if sort_by == "model_name": available_loras = sorted( available_loras, - key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower() + key=lambda x: ( + (x.get("model_name") or x.get("file_name", "")).lower(), + x.get("file_path", "").lower() + ) ) else: # Default to filename available_loras = sorted( available_loras, - key=lambda x: x.get("file_name", "").lower() + key=lambda x: ( + x.get("file_name", "").lower(), + x.get("file_path", "").lower() + ) ) # Return minimal data needed for cycling diff --git a/py/services/model_cache.py b/py/services/model_cache.py index ac0f273c..9b396634 100644 --- a/py/services/model_cache.py +++ b/py/services/model_cache.py @@ -221,33 +221,45 @@ class ModelCache: start_time = time.perf_counter() reverse = (order == 'desc') if sort_key == 'name': - # Natural sort by configured display name, case-insensitive + # Natural sort by configured display name, case-insensitive, with file_path as tie-breaker result = natsorted( data, - key=lambda x: self._get_display_name(x).lower(), + key=lambda x: ( + self._get_display_name(x).lower(), + x.get('file_path', '').lower() + ), reverse=reverse ) elif sort_key == 'date': - # Sort by modified timestamp (use .get() with default to handle missing fields) + # Sort by modified timestamp, fallback to name and path for stability result = sorted( data, - key=lambda x: x.get('modified', 0.0), + key=lambda x: ( + x.get('modified', 0.0), + self._get_display_name(x).lower(), + x.get('file_path', '').lower() + ), reverse=reverse ) elif sort_key == 'size': - # Sort by file size (use .get() with default to handle missing fields) + # Sort by file size, fallback to name and path for stability result = sorted( data, - key=lambda x: x.get('size', 0), + key=lambda x: ( + x.get('size', 0), + self._get_display_name(x).lower(), + x.get('file_path', '').lower() + ), reverse=reverse ) elif sort_key == 'usage': - # Sort by usage count, fallback to 0, then name for stability + # Sort by usage count, fallback to 0, then name and path for stability return sorted( data, key=lambda x: ( x.get('usage_count', 0), - self._get_display_name(x).lower() + self._get_display_name(x).lower(), + x.get('file_path', '').lower() ), reverse=reverse ) diff --git a/py/services/recipe_cache.py b/py/services/recipe_cache.py index 602795f8..7c0c499c 100644 --- a/py/services/recipe_cache.py +++ b/py/services/recipe_cache.py @@ -135,7 +135,8 @@ class RecipeCache: """Sort cached views. Caller must hold ``_lock``.""" self.sorted_by_name = natsorted( - self.raw_data, key=lambda x: x.get("title", "").lower() + self.raw_data, + key=lambda x: (x.get("title", "").lower(), x.get("file_path", "").lower()), ) if not name_only: self.sorted_by_date = sorted( diff --git a/py/utils/utils.py b/py/utils/utils.py index ada56f50..75b19738 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -173,10 +173,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str: # Collapse repeated replacement characters to a single instance if replacement: sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized) - sanitized = sanitized.strip(replacement) - - # Remove trailing spaces or periods which are invalid on Windows - sanitized = sanitized.rstrip(" .") + # Combine stripping to be idempotent: + # Right side: strip replacement, space, and dot (Windows restriction) + # Left side: strip replacement and space (leading dots are allowed) + sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement) + else: + # If no replacement, just strip spaces and dots from right, spaces from left + sanitized = sanitized.rstrip(" .").lstrip(" ") if not sanitized: return "unnamed" diff --git a/static/js/managers/BatchImportManager.js b/static/js/managers/BatchImportManager.js index ff100398..53984a6b 100644 --- a/static/js/managers/BatchImportManager.js +++ b/static/js/managers/BatchImportManager.js @@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { translate } from '../utils/i18nHelpers.js'; import { WS_ENDPOINTS } from '../api/apiConfig.js'; +import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; /** * Manager for batch importing recipes from multiple images @@ -34,6 +35,14 @@ export class BatchImportManager { */ initialize() { this.initialized = true; + + // Add event listener for persisting "Skip images without metadata" choice + const skipNoMetadata = document.getElementById('batchSkipNoMetadata'); + if (skipNoMetadata) { + skipNoMetadata.addEventListener('change', (e) => { + setStorageItem('batch_import_skip_no_metadata', e.target.checked); + }); + } } /** @@ -61,7 +70,10 @@ export class BatchImportManager { if (tagsInput) tagsInput.value = ''; const skipNoMetadata = document.getElementById('batchSkipNoMetadata'); - if (skipNoMetadata) skipNoMetadata.checked = true; + if (skipNoMetadata) { + // Load preference from storage, defaulting to true + skipNoMetadata.checked = getStorageItem('batch_import_skip_no_metadata', true); + } const recursiveCheck = document.getElementById('batchRecursiveCheck'); if (recursiveCheck) recursiveCheck.checked = true; diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index 0f501605..5ebecaaf 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -267,4 +267,431 @@ describe('AutoComplete widget interactions', () => { const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0; expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore); }); + + it('replaces entire multi-word phrase when it matches selected tag (Danbooru convention)', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + { tag_name: 'looking_away', category: 0, post_count: 5678 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + + await autoComplete.insertSelection('looking_to_the_side'); + + expect(input.value).toBe('looking_to_the_side, '); + expect(autoComplete.dropdown.style.display).toBe('none'); + expect(input.focus).toHaveBeenCalled(); + }); + + it('replaces only last token when typing partial match (e.g., "hello 1gi" -> "1girl")', async () => { + const mockTags = [ + { tag_name: '1girl', category: 4, post_count: 500000 }, + { tag_name: '1boy', category: 4, post_count: 300000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('hello 1gi'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'hello 1gi'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'hello 1gi'; + + await autoComplete.insertSelection('1girl'); + + expect(input.value).toBe('hello 1girl, '); + }); + + it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + { tag_name: 'blue_eyes', category: 0, post_count: 80000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'blue hair'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'blue hair'; + + await autoComplete.insertSelection('blue_hair'); + + expect(input.value).toBe('blue_hair, '); + }); + + it('handles multi-word phrase with preceding text correctly', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('1girl, looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '1girl, looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + expect(input.value).toBe('1girl, looking_to_the_side, '); + }); + + it('replaces entire command and search term when using command mode with multi-word phrase', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 4, post_count: 1234 }, + { tag_name: 'looking_away', category: 4, post_count: 5678 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // Simulate "/char looking to the side" input + caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '/char looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + // Set up command mode state + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = { categories: [4, 11], label: 'Character' }; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = '/char looking to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Command part should be replaced along with search term + expect(input.value).toBe('looking_to_the_side, '); + }); + + it('replaces only last token when multi-word query does not exactly match selected tag', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + { tag_name: 'blue_eyes', category: 0, post_count: 80000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // User types "looking to the blue" but selects "blue_hair" (doesn't match entire phrase) + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the blue'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the blue'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the blue'; + + await autoComplete.insertSelection('blue_hair'); + + // Only "blue" should be replaced, not the entire phrase + expect(input.value).toBe('looking to the blue_hair, '); + }); + + it('handles multiple consecutive spaces in multi-word phrase correctly', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // Input with multiple spaces between words + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Multiple spaces should be normalized to single underscores for matching + expect(input.value).toBe('looking_to_the_side, '); + }); + + it('handles command mode with partial match replacing only last token', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // Command mode but selected tag doesn't match entire search phrase + caretHelperInstance.getBeforeCursor.mockReturnValue('/general looking to the blue'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '/general looking to the blue'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + // Command mode with activeCommand + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = { categories: [0, 7], label: 'General' }; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = '/general looking to the blue'; + + await autoComplete.insertSelection('blue_hair'); + + // In command mode, the entire command + search term should be replaced + expect(input.value).toBe('blue_hair, '); + }); + + it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // User types partial phrase "looking to the" and selects "looking_to_the_side" + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Entire phrase should be replaced with selected tag (with underscores) + expect(input.value).toBe('looking_to_the_side, '); + }); + + it('inserts tag with underscores regardless of space replacement setting', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('blue'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'blue'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + + await autoComplete.insertSelection('blue_hair'); + + // Tag should be inserted with underscores, not spaces + expect(input.value).toBe('blue_hair, '); + }); + + it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // User types suffix "to the side" and selects "looking_to_the_side" + caretHelperInstance.getBeforeCursor.mockReturnValue('to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Entire phrase should be replaced with selected tag + expect(input.value).toBe('looking_to_the_side, '); + }); }); diff --git a/tests/test_tag_fts_index.py b/tests/test_tag_fts_index.py index cb5aebb3..6784b790 100644 --- a/tests/test_tag_fts_index.py +++ b/tests/test_tag_fts_index.py @@ -242,36 +242,70 @@ class TestTagFTSIndexSearch: ) def test_search_pagination_ordering_consistency(self, populated_fts): - """Test that pagination maintains consistent ordering.""" + """Test that pagination maintains consistent ordering by post_count.""" page1 = populated_fts.search("1", limit=10, offset=0) page2 = populated_fts.search("1", limit=10, offset=10) assert len(page1) > 0, "Page 1 should have results" assert len(page2) > 0, "Page 2 should have results" - # Page 2 scores should all be <= Page 1 min score - page1_min_score = min(r["rank_score"] for r in page1) - page2_max_score = max(r["rank_score"] for r in page2) + # Page 2 max post_count should be <= Page 1 min post_count + page1_min_posts = min(r["post_count"] for r in page1) + page2_max_posts = max(r["post_count"] for r in page2) - assert page2_max_score <= page1_min_score, ( - f"Page 2 max score ({page2_max_score}) should be <= Page 1 min score ({page1_min_score})" + assert page2_max_posts <= page1_min_posts, ( + f"Page 2 max post_count ({page2_max_posts}) should be <= Page 1 min post_count ({page1_min_posts})" ) - def test_search_rank_score_includes_popularity_weight(self, populated_fts): - """Test that rank_score includes post_count popularity weighting.""" + def test_search_returns_popular_tags_higher(self, populated_fts): + """Test that search returns popular tags (higher post_count) first.""" results = populated_fts.search("1", limit=5) assert len(results) >= 2, "Need at least 2 results to compare" - # 1girl has 6M posts, should have higher rank_score than tags with fewer posts + # 1girl has 6M posts, should be ranked first girl_result = next((r for r in results if r["tag_name"] == "1girl"), None) assert girl_result is not None, "1girl should be in results" + assert results[0]["tag_name"] == "1girl", ( + "1girl should be first due to highest post_count" + ) # Find a tag with significantly fewer posts low_post_result = next((r for r in results if r["post_count"] < 10000), None) if low_post_result: - assert girl_result["rank_score"] > low_post_result["rank_score"], ( - f"1girl (6M posts) should have higher score than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)" + assert girl_result["post_count"] > low_post_result["post_count"], ( + f"1girl (6M posts) should have higher post_count than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)" + ) + + def test_search_popularity_ordering(self, populated_fts): + """Test that results are ordered by post_count (popularity).""" + results = populated_fts.search("1", limit=20) + + # Get 1girl and 1boy results for comparison + girl_result = next((r for r in results if r["tag_name"] == "1girl"), None) + boy_result = next((r for r in results if r["tag_name"] == "1boy"), None) + + assert girl_result is not None, "1girl should be in results" + assert boy_result is not None, "1boy should be in results" + + # 1girl: 6M posts, 1boy: 1.4M posts + assert girl_result["post_count"] == 6008644, "1girl should have 6M posts" + assert boy_result["post_count"] == 1405457, "1boy should have 1.4M posts" + + # 1girl should rank higher due to higher post_count + girl_rank = results.index(girl_result) + boy_rank = results.index(boy_result) + assert girl_rank < boy_rank, ( + f"1girl should rank higher than 1boy due to higher post_count " + f"(girl rank: {girl_rank}, boy rank: {boy_rank})" + ) + + # Verify results are sorted by post_count descending + for i in range(len(results) - 1): + assert results[i]["post_count"] >= results[i + 1]["post_count"], ( + f"Results should be sorted by post_count descending: " + f"{results[i]['tag_name']} ({results[i]['post_count']}) >= " + f"{results[i + 1]['tag_name']} ({results[i + 1]['post_count']})" ) diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 421b23a5..3c7c23ce 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -1905,10 +1905,38 @@ class AutoComplete { // For regular tag autocomplete (no command), only replace the last space-separated token // This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, " + // However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side" + // matching "looking_to_the_side"), replace the entire phrase instead of just the last word. // Command mode (e.g., "/char miku") should replace the entire command+search let searchTerm = fullSearchTerm; if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) { - searchTerm = this._getLastSpaceToken(fullSearchTerm); + // Check if the selectedItem exists and its tag_name matches the full search term + // when converted to underscore format (Danbooru convention) + const selectedItem = this.selectedIndex >= 0 ? this.items[this.selectedIndex] : null; + const selectedTagName = selectedItem && typeof selectedItem === 'object' && 'tag_name' + ? selectedItem.tag_name + : null; + + // Convert full search term to underscore format and check if it matches selected tag + // Normalize multiple spaces to single underscore for matching (e.g., "looking to the side" -> "looking_to_the_side") + const underscoreVersion = fullSearchTerm.replace(/ +/g, '_').toLowerCase(); + const selectedTagLower = selectedTagName?.toLowerCase() ?? ''; + + // If multi-word search term is a prefix or suffix of the selected tag, + // replace the entire phrase. This handles cases where user types partial tag name. + // Examples: + // - "looking to the" -> "looking_to_the_side" (prefix match) + // - "to the side" -> "looking_to_the_side" (suffix match) + // - "looking to the side" -> "looking_to_the_side" (exact match) + if (fullSearchTerm.includes(' ') && ( + selectedTagLower.startsWith(underscoreVersion) || + selectedTagLower.endsWith(underscoreVersion) || + underscoreVersion === selectedTagLower + )) { + searchTerm = fullSearchTerm; + } else { + searchTerm = this._getLastSpaceToken(fullSearchTerm); + } } const searchStartPos = caretPos - searchTerm.length;