Merge branch 'willmiao:main' into claude/add-webp-image-support-t8kG9

This commit is contained in:
EnragedAntelope
2026-03-17 08:37:46 -04:00
committed by GitHub
19 changed files with 576 additions and 39 deletions

View File

@@ -14,7 +14,8 @@
"backToTop": "Nach oben",
"settings": "Einstellungen",
"help": "Hilfe",
"add": "Hinzufügen"
"add": "Hinzufügen",
"close": "Schließen"
},
"status": {
"loading": "Wird geladen...",

View File

@@ -14,7 +14,8 @@
"backToTop": "Back to top",
"settings": "Settings",
"help": "Help",
"add": "Add"
"add": "Add",
"close": "Close"
},
"status": {
"loading": "Loading...",

View File

@@ -14,7 +14,8 @@
"backToTop": "Volver arriba",
"settings": "Configuración",
"help": "Ayuda",
"add": "Añadir"
"add": "Añadir",
"close": "Cerrar"
},
"status": {
"loading": "Cargando...",

View File

@@ -14,7 +14,8 @@
"backToTop": "Retour en haut",
"settings": "Paramètres",
"help": "Aide",
"add": "Ajouter"
"add": "Ajouter",
"close": "Fermer"
},
"status": {
"loading": "Chargement...",

View File

@@ -14,7 +14,8 @@
"backToTop": "חזרה למעלה",
"settings": "הגדרות",
"help": "עזרה",
"add": "הוספה"
"add": "הוספה",
"close": "סגור"
},
"status": {
"loading": "טוען...",

View File

@@ -14,7 +14,8 @@
"backToTop": "トップへ戻る",
"settings": "設定",
"help": "ヘルプ",
"add": "追加"
"add": "追加",
"close": "閉じる"
},
"status": {
"loading": "読み込み中...",

View File

@@ -14,7 +14,8 @@
"backToTop": "맨 위로",
"settings": "설정",
"help": "도움말",
"add": "추가"
"add": "추가",
"close": "닫기"
},
"status": {
"loading": "로딩 중...",

View File

@@ -14,7 +14,8 @@
"backToTop": "Наверх",
"settings": "Настройки",
"help": "Справка",
"add": "Добавить"
"add": "Добавить",
"close": "Закрыть"
},
"status": {
"loading": "Загрузка...",

View File

@@ -14,7 +14,8 @@
"backToTop": "返回顶部",
"settings": "设置",
"help": "帮助",
"add": "添加"
"add": "添加",
"close": "关闭"
},
"status": {
"loading": "加载中...",

View File

@@ -14,7 +14,8 @@
"backToTop": "回到頂部",
"settings": "設定",
"help": "說明",
"add": "新增"
"add": "新增",
"close": "關閉"
},
"status": {
"loading": "載入中...",

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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(

View File

@@ -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"

View File

@@ -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;

View File

@@ -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, ');
});
});

View File

@@ -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']})"
)

View File

@@ -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;