mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: Introduce "No tags" filter option for models and recipes. fixes #728
This commit is contained in:
@@ -200,6 +200,7 @@
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"noTags": "Keine Tags",
|
||||
"clearAll": "Alle Filter löschen"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "License",
|
||||
"noCreditRequired": "No Credit Required",
|
||||
"allowSellingGeneratedContent": "Allow Selling",
|
||||
"noTags": "No tags",
|
||||
"clearAll": "Clear All Filters"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"noTags": "Sin etiquetas",
|
||||
"clearAll": "Limpiar todos los filtros"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
"noTags": "Aucun tag",
|
||||
"clearAll": "Effacer tous les filtres"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"noTags": "ללא תגיות",
|
||||
"clearAll": "נקה את כל המסננים"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"noTags": "タグなし",
|
||||
"clearAll": "すべてのフィルタをクリア"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"noTags": "태그 없음",
|
||||
"clearAll": "모든 필터 지우기"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"noTags": "Без тегов",
|
||||
"clearAll": "Очистить все фильтры"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"noTags": "无标签",
|
||||
"clearAll": "清除所有筛选"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"noTags": "無標籤",
|
||||
"clearAll": "清除所有篩選"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -161,15 +161,25 @@ class ModelFilterSet:
|
||||
include_tags = {tag for tag in tag_filters if tag}
|
||||
|
||||
if include_tags:
|
||||
def matches_include(item_tags):
|
||||
if not item_tags and "__no_tags__" in include_tags:
|
||||
return True
|
||||
return any(tag in include_tags for tag in (item_tags or []))
|
||||
|
||||
items = [
|
||||
item for item in items
|
||||
if any(tag in include_tags for tag in (item.get("tags", []) or []))
|
||||
if matches_include(item.get("tags"))
|
||||
]
|
||||
|
||||
if exclude_tags:
|
||||
def matches_exclude(item_tags):
|
||||
if not item_tags and "__no_tags__" in exclude_tags:
|
||||
return True
|
||||
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||
|
||||
items = [
|
||||
item for item in items
|
||||
if not any(tag in exclude_tags for tag in (item.get("tags", []) or []))
|
||||
if not matches_exclude(item.get("tags"))
|
||||
]
|
||||
|
||||
model_types = criteria.model_types or []
|
||||
|
||||
@@ -1162,15 +1162,25 @@ class RecipeScanner:
|
||||
include_tags = {tag for tag in tag_spec if tag}
|
||||
|
||||
if include_tags:
|
||||
def matches_include(item_tags):
|
||||
if not item_tags and "__no_tags__" in include_tags:
|
||||
return True
|
||||
return any(tag in include_tags for tag in (item_tags or []))
|
||||
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(tag in include_tags for tag in (item.get('tags', []) or []))
|
||||
if matches_include(item.get('tags'))
|
||||
]
|
||||
|
||||
if exclude_tags:
|
||||
def matches_exclude(item_tags):
|
||||
if not item_tags and "__no_tags__" in exclude_tags:
|
||||
return True
|
||||
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if not any(tag in exclude_tags for tag in (item.get('tags', []) or []))
|
||||
if not matches_exclude(item.get('tags'))
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -242,6 +242,20 @@
|
||||
border-color: var(--lora-error-border);
|
||||
}
|
||||
|
||||
/* Subtle styling for special system tags like "No tags" */
|
||||
.filter-tag.special-tag {
|
||||
border-style: dashed;
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Ensure solid border and full opacity when active or excluded */
|
||||
.filter-tag.special-tag.active,
|
||||
.filter-tag.special-tag.exclude {
|
||||
border-style: solid;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Tag filter styles */
|
||||
.tag-filter {
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
export class FilterManager {
|
||||
constructor(options = {}) {
|
||||
@@ -131,6 +132,28 @@ export class FilterManager {
|
||||
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
||||
tagsContainer.appendChild(tagEl);
|
||||
});
|
||||
|
||||
// Add "No tags" as a special filter at the end
|
||||
const noTagsEl = document.createElement('div');
|
||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||
const noTagsKey = '__no_tags__';
|
||||
noTagsEl.dataset.tag = noTagsKey;
|
||||
noTagsEl.innerHTML = noTagsLabel;
|
||||
|
||||
noTagsEl.addEventListener('click', async () => {
|
||||
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||
const newState = this.getNextTriStateState(currentState);
|
||||
this.setTagFilterState(noTagsKey, newState);
|
||||
this.applyTagElementState(noTagsEl, newState);
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none');
|
||||
tagsContainer.appendChild(noTagsEl);
|
||||
}
|
||||
|
||||
initializeLicenseFilters() {
|
||||
|
||||
104
tests/services/test_no_tags_filter.py
Normal file
104
tests/services/test_no_tags_filter.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import pytest
|
||||
from py.services.model_query import ModelFilterSet, FilterCriteria
|
||||
from py.services.recipe_scanner import RecipeScanner
|
||||
from pathlib import Path
|
||||
from py.config import config
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
class StubSettings:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
# --- Model Filtering Tests ---
|
||||
|
||||
def test_model_filter_set_no_tags_include():
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["tag1"]},
|
||||
{"name": "m2", "tags": []},
|
||||
{"name": "m3", "tags": None},
|
||||
{"name": "m4", "tags": ["tag2"]},
|
||||
]
|
||||
|
||||
# Include __no_tags__
|
||||
criteria = FilterCriteria(tags={"__no_tags__": "include"})
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 2
|
||||
assert {item["name"] for item in result} == {"m2", "m3"}
|
||||
|
||||
def test_model_filter_set_no_tags_exclude():
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["tag1"]},
|
||||
{"name": "m2", "tags": []},
|
||||
{"name": "m3", "tags": None},
|
||||
{"name": "m4", "tags": ["tag2"]},
|
||||
]
|
||||
|
||||
# Exclude __no_tags__
|
||||
criteria = FilterCriteria(tags={"__no_tags__": "exclude"})
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 2
|
||||
assert {item["name"] for item in result} == {"m1", "m4"}
|
||||
|
||||
def test_model_filter_set_no_tags_mixed():
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["tag1"]},
|
||||
{"name": "m2", "tags": []},
|
||||
{"name": "m3", "tags": None},
|
||||
{"name": "m4", "tags": ["tag1", "tag2"]},
|
||||
]
|
||||
|
||||
# Include tag1 AND __no_tags__
|
||||
criteria = FilterCriteria(tags={"tag1": "include", "__no_tags__": "include"})
|
||||
result = filter_set.apply(data, criteria)
|
||||
# m1 (tag1), m2 (no tags), m3 (no tags), m4 (tag1)
|
||||
assert len(result) == 4
|
||||
|
||||
# --- Recipe Filtering Tests ---
|
||||
|
||||
class StubLoraScanner:
|
||||
def __init__(self):
|
||||
self._cache = SimpleNamespace(raw_data=[], version_index={})
|
||||
async def get_cached_data(self):
|
||||
return self._cache
|
||||
async def refresh_cache(self, force=False):
|
||||
pass
|
||||
|
||||
@pytest.fixture
|
||||
def recipe_scanner(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||
stub = StubLoraScanner()
|
||||
scanner = RecipeScanner(lora_scanner=stub)
|
||||
return scanner
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipe_scanner_no_tags_filter(recipe_scanner):
|
||||
scanner = recipe_scanner
|
||||
|
||||
# Mock some recipe data
|
||||
recipes = [
|
||||
{"id": "r1", "tags": ["tag1"], "title": "R1"},
|
||||
{"id": "r2", "tags": [], "title": "R2"},
|
||||
{"id": "r3", "tags": None, "title": "R3"},
|
||||
]
|
||||
|
||||
# We need to inject these into the scanner's cache
|
||||
# Since get_paginated_data calls get_cached_data() which we stubbed
|
||||
scanner._cache = SimpleNamespace(
|
||||
raw_data=recipes,
|
||||
sorted_by_date=recipes,
|
||||
sorted_by_name=recipes
|
||||
)
|
||||
|
||||
# Test Include __no_tags__
|
||||
result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "include"}})
|
||||
assert len(result["items"]) == 2
|
||||
assert {item["id"] for item in result["items"]} == {"r2", "r3"}
|
||||
|
||||
# Test Exclude __no_tags__
|
||||
result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "exclude"}})
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["id"] == "r1"
|
||||
Reference in New Issue
Block a user