mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 13:12:12 -03:00
Merge branch 'willmiao:main' into claude/add-webp-image-support-t8kG9
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Nach oben",
|
||||
"settings": "Einstellungen",
|
||||
"help": "Hilfe",
|
||||
"add": "Hinzufügen"
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Back to top",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"add": "Add"
|
||||
"add": "Add",
|
||||
"close": "Close"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Volver arriba",
|
||||
"settings": "Configuración",
|
||||
"help": "Ayuda",
|
||||
"add": "Añadir"
|
||||
"add": "Añadir",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Retour en haut",
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide",
|
||||
"add": "Ajouter"
|
||||
"add": "Ajouter",
|
||||
"close": "Fermer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "חזרה למעלה",
|
||||
"settings": "הגדרות",
|
||||
"help": "עזרה",
|
||||
"add": "הוספה"
|
||||
"add": "הוספה",
|
||||
"close": "סגור"
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "トップへ戻る",
|
||||
"settings": "設定",
|
||||
"help": "ヘルプ",
|
||||
"add": "追加"
|
||||
"add": "追加",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "맨 위로",
|
||||
"settings": "설정",
|
||||
"help": "도움말",
|
||||
"add": "추가"
|
||||
"add": "추가",
|
||||
"close": "닫기"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Наверх",
|
||||
"settings": "Настройки",
|
||||
"help": "Справка",
|
||||
"add": "Добавить"
|
||||
"add": "Добавить",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "返回顶部",
|
||||
"settings": "设置",
|
||||
"help": "帮助",
|
||||
"add": "添加"
|
||||
"add": "添加",
|
||||
"close": "关闭"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "回到頂部",
|
||||
"settings": "設定",
|
||||
"help": "說明",
|
||||
"add": "新增"
|
||||
"add": "新增",
|
||||
"close": "關閉"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']})"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user