mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
4 Commits
b5a0725d2c
...
9e81c33f8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e81c33f8a | ||
|
|
22c0dbd734 | ||
|
|
d0c58472be | ||
|
|
b3c530bf36 |
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Nach oben",
|
"backToTop": "Nach oben",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"add": "Hinzufügen"
|
"add": "Hinzufügen",
|
||||||
|
"close": "Schließen"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Back to top",
|
"backToTop": "Back to top",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"add": "Add"
|
"add": "Add",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Volver arriba",
|
"backToTop": "Volver arriba",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"add": "Añadir"
|
"add": "Añadir",
|
||||||
|
"close": "Cerrar"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Retour en haut",
|
"backToTop": "Retour en haut",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"add": "Ajouter"
|
"add": "Ajouter",
|
||||||
|
"close": "Fermer"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "חזרה למעלה",
|
"backToTop": "חזרה למעלה",
|
||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה"
|
"add": "הוספה",
|
||||||
|
"close": "סגור"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "トップへ戻る",
|
"backToTop": "トップへ戻る",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加"
|
"add": "追加",
|
||||||
|
"close": "閉じる"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "맨 위로",
|
"backToTop": "맨 위로",
|
||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가"
|
"add": "추가",
|
||||||
|
"close": "닫기"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Наверх",
|
"backToTop": "Наверх",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить"
|
"add": "Добавить",
|
||||||
|
"close": "Закрыть"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "返回顶部",
|
"backToTop": "返回顶部",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加"
|
"add": "添加",
|
||||||
|
"close": "关闭"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "回到頂部",
|
"backToTop": "回到頂部",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增"
|
"add": "新增",
|
||||||
|
"close": "關閉"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
|
|||||||
@@ -173,10 +173,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
|||||||
# Collapse repeated replacement characters to a single instance
|
# Collapse repeated replacement characters to a single instance
|
||||||
if replacement:
|
if replacement:
|
||||||
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
|
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
|
||||||
sanitized = sanitized.strip(replacement)
|
# Combine stripping to be idempotent:
|
||||||
|
# Right side: strip replacement, space, and dot (Windows restriction)
|
||||||
# Remove trailing spaces or periods which are invalid on Windows
|
# Left side: strip replacement and space (leading dots are allowed)
|
||||||
sanitized = sanitized.rstrip(" .")
|
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:
|
if not sanitized:
|
||||||
return "unnamed"
|
return "unnamed"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
||||||
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager for batch importing recipes from multiple images
|
* Manager for batch importing recipes from multiple images
|
||||||
@@ -34,6 +35,14 @@ export class BatchImportManager {
|
|||||||
*/
|
*/
|
||||||
initialize() {
|
initialize() {
|
||||||
this.initialized = true;
|
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 = '';
|
if (tagsInput) tagsInput.value = '';
|
||||||
|
|
||||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
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');
|
const recursiveCheck = document.getElementById('batchRecursiveCheck');
|
||||||
if (recursiveCheck) recursiveCheck.checked = true;
|
if (recursiveCheck) recursiveCheck.checked = true;
|
||||||
|
|||||||
@@ -267,4 +267,431 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
|
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
|
||||||
expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
|
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, ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,36 +242,70 @@ class TestTagFTSIndexSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_search_pagination_ordering_consistency(self, populated_fts):
|
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)
|
page1 = populated_fts.search("1", limit=10, offset=0)
|
||||||
page2 = populated_fts.search("1", limit=10, offset=10)
|
page2 = populated_fts.search("1", limit=10, offset=10)
|
||||||
|
|
||||||
assert len(page1) > 0, "Page 1 should have results"
|
assert len(page1) > 0, "Page 1 should have results"
|
||||||
assert len(page2) > 0, "Page 2 should have results"
|
assert len(page2) > 0, "Page 2 should have results"
|
||||||
|
|
||||||
# Page 2 scores should all be <= Page 1 min score
|
# Page 2 max post_count should be <= Page 1 min post_count
|
||||||
page1_min_score = min(r["rank_score"] for r in page1)
|
page1_min_posts = min(r["post_count"] for r in page1)
|
||||||
page2_max_score = max(r["rank_score"] for r in page2)
|
page2_max_posts = max(r["post_count"] for r in page2)
|
||||||
|
|
||||||
assert page2_max_score <= page1_min_score, (
|
assert page2_max_posts <= page1_min_posts, (
|
||||||
f"Page 2 max score ({page2_max_score}) should be <= Page 1 min score ({page1_min_score})"
|
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):
|
def test_search_returns_popular_tags_higher(self, populated_fts):
|
||||||
"""Test that rank_score includes post_count popularity weighting."""
|
"""Test that search returns popular tags (higher post_count) first."""
|
||||||
results = populated_fts.search("1", limit=5)
|
results = populated_fts.search("1", limit=5)
|
||||||
|
|
||||||
assert len(results) >= 2, "Need at least 2 results to compare"
|
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)
|
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 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
|
# Find a tag with significantly fewer posts
|
||||||
low_post_result = next((r for r in results if r["post_count"] < 10000), None)
|
low_post_result = next((r for r in results if r["post_count"] < 10000), None)
|
||||||
if low_post_result:
|
if low_post_result:
|
||||||
assert girl_result["rank_score"] > low_post_result["rank_score"], (
|
assert girl_result["post_count"] > low_post_result["post_count"], (
|
||||||
f"1girl (6M posts) should have higher score than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)"
|
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']})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1905,10 +1905,38 @@ class AutoComplete {
|
|||||||
|
|
||||||
// For regular tag autocomplete (no command), only replace the last space-separated token
|
// For regular tag autocomplete (no command), only replace the last space-separated token
|
||||||
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
|
// 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
|
// Command mode (e.g., "/char miku") should replace the entire command+search
|
||||||
let searchTerm = fullSearchTerm;
|
let searchTerm = fullSearchTerm;
|
||||||
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
|
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;
|
const searchStartPos = caretPos - searchTerm.length;
|
||||||
|
|||||||
Reference in New Issue
Block a user