mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
Merge pull request #3629 from Spinnich/feat/filter-by-custom-tags
feat(roms): filter games by custom tags
This commit is contained in:
@@ -652,4 +652,5 @@ class RomFiltersDict(TypedDict):
|
||||
player_counts: list[str]
|
||||
regions: list[str]
|
||||
languages: list[str]
|
||||
tags: list[str]
|
||||
platforms: list[int]
|
||||
|
||||
@@ -462,6 +462,16 @@ def get_roms(
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
tags: Annotated[
|
||||
list[str] | None,
|
||||
Query(
|
||||
description=(
|
||||
"Associated custom tag (parsed from the filename, e.g. Proto, Beta,"
|
||||
" Demo). Multiple values are allowed by repeating the parameter, and"
|
||||
" results that match any of the values will be returned."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
# Logic operators for multi-value filters
|
||||
genres_logic: Annotated[
|
||||
str,
|
||||
@@ -523,6 +533,12 @@ def get_roms(
|
||||
description="Logic operator for metadata providers filter: 'any' (OR), 'all' (AND) or 'none' (NOT).",
|
||||
),
|
||||
] = "any",
|
||||
tags_logic: Annotated[
|
||||
str,
|
||||
Query(
|
||||
description="Logic operator for tags filter: 'any' (OR), 'all' (AND) or 'none' (NOT).",
|
||||
),
|
||||
] = "any",
|
||||
order_by: Annotated[
|
||||
str,
|
||||
Query(
|
||||
@@ -587,6 +603,7 @@ def get_roms(
|
||||
languages=languages,
|
||||
player_counts=player_counts,
|
||||
metadata_providers=metadata_providers,
|
||||
tags=tags,
|
||||
# Logic operators
|
||||
genres_logic=genres_logic,
|
||||
franchises_logic=franchises_logic,
|
||||
@@ -598,6 +615,7 @@ def get_roms(
|
||||
statuses_logic=statuses_logic,
|
||||
player_counts_logic=player_counts_logic,
|
||||
metadata_providers_logic=metadata_providers_logic,
|
||||
tags_logic=tags_logic,
|
||||
group_by_meta_id=group_by_meta_id,
|
||||
updated_after=updated_after,
|
||||
include_file_stats=True,
|
||||
@@ -626,6 +644,7 @@ def get_roms(
|
||||
or languages
|
||||
or player_counts
|
||||
or metadata_providers
|
||||
or tags
|
||||
or updated_after
|
||||
or matched is not None
|
||||
or favorite is not None
|
||||
@@ -663,6 +682,7 @@ def get_roms(
|
||||
player_counts=[],
|
||||
regions=[],
|
||||
languages=[],
|
||||
tags=[],
|
||||
platforms=[],
|
||||
)
|
||||
if with_filter_values:
|
||||
|
||||
@@ -369,6 +369,7 @@ class DBCollectionsHandler(DBBaseHandler):
|
||||
age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating")
|
||||
regions = convert_legacy_filter("regions", "selected_region")
|
||||
languages = convert_legacy_filter("languages", "selected_language")
|
||||
tags = convert_legacy_filter("tags", "selected_tag")
|
||||
statuses = convert_legacy_filter("statuses", "selected_status")
|
||||
|
||||
# Use the existing filter_roms method with the stored criteria
|
||||
@@ -397,6 +398,7 @@ class DBCollectionsHandler(DBBaseHandler):
|
||||
statuses=statuses,
|
||||
regions=regions,
|
||||
languages=languages,
|
||||
tags=tags,
|
||||
metadata_providers=criteria.get("metadata_providers"),
|
||||
# Logic operators for multi-value filters
|
||||
genres_logic=criteria.get("genres_logic", "any"),
|
||||
@@ -408,6 +410,7 @@ class DBCollectionsHandler(DBBaseHandler):
|
||||
languages_logic=criteria.get("languages_logic", "any"),
|
||||
statuses_logic=criteria.get("statuses_logic", "any"),
|
||||
metadata_providers_logic=criteria.get("metadata_providers_logic", "any"),
|
||||
tags_logic=criteria.get("tags_logic", "any"),
|
||||
user_id=user_id,
|
||||
order_by=criteria.get("order_by", "name"),
|
||||
order_dir=criteria.get("order_dir", "asc"),
|
||||
|
||||
@@ -696,6 +696,19 @@ class DBRomsHandler(DBBaseHandler):
|
||||
condition = op(Rom.languages, values, session=session)
|
||||
return query.filter(~condition) if match_none else query.filter(condition)
|
||||
|
||||
def _filter_by_tags(
|
||||
self,
|
||||
query: Query,
|
||||
*,
|
||||
session: Session,
|
||||
values: Sequence[str],
|
||||
match_all: bool = False,
|
||||
match_none: bool = False,
|
||||
) -> Query:
|
||||
op = json_array_contains_all if match_all else json_array_contains_any
|
||||
condition = op(Rom.tags, values, session=session)
|
||||
return query.filter(~condition) if match_none else query.filter(condition)
|
||||
|
||||
def _filter_by_player_counts(
|
||||
self,
|
||||
query: Query,
|
||||
@@ -771,6 +784,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
languages: Sequence[str] | None = None,
|
||||
player_counts: Sequence[str] | None = None,
|
||||
metadata_providers: Sequence[str] | None = None,
|
||||
tags: Sequence[str] | None = None,
|
||||
# Logic operators for multi-value filters
|
||||
genres_logic: str = "any",
|
||||
franchises_logic: str = "any",
|
||||
@@ -782,6 +796,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
statuses_logic: str = "any",
|
||||
player_counts_logic: str = "any",
|
||||
metadata_providers_logic: str = "any",
|
||||
tags_logic: str = "any",
|
||||
user_id: int | None = None,
|
||||
updated_after: datetime | None = None,
|
||||
include_file_stats: bool = False,
|
||||
@@ -1015,6 +1030,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
metadata_providers_logic,
|
||||
self._filter_by_metadata_providers,
|
||||
),
|
||||
(tags, tags_logic, self._filter_by_tags),
|
||||
]
|
||||
|
||||
for values, logic, filter_func in filters_to_apply:
|
||||
@@ -1154,6 +1170,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
languages=kwargs.get("languages", None),
|
||||
player_counts=kwargs.get("player_counts", None),
|
||||
metadata_providers=kwargs.get("metadata_providers", None),
|
||||
tags=kwargs.get("tags", None),
|
||||
# Logic operators for multi-value filters
|
||||
genres_logic=kwargs.get("genres_logic", "any"),
|
||||
franchises_logic=kwargs.get("franchises_logic", "any"),
|
||||
@@ -1165,6 +1182,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
statuses_logic=kwargs.get("statuses_logic", "any"),
|
||||
player_counts_logic=kwargs.get("player_counts_logic", "any"),
|
||||
metadata_providers_logic=kwargs.get("metadata_providers_logic", "any"),
|
||||
tags_logic=kwargs.get("tags_logic", "any"),
|
||||
user_id=kwargs.get("user_id", None),
|
||||
group_by_meta_id=kwargs.get("group_by_meta_id", False),
|
||||
include_files=kwargs.get("include_files", False),
|
||||
@@ -1831,10 +1849,11 @@ class DBRomsHandler(DBBaseHandler):
|
||||
player_counts = set()
|
||||
regions = set()
|
||||
languages = set()
|
||||
tags = set()
|
||||
platforms = set()
|
||||
|
||||
for row in session.execute(statement):
|
||||
g, f, cl, co, gm, ar, pc, rg, lg, pid = row
|
||||
g, f, cl, co, gm, ar, pc, rg, lg, tg, pid = row
|
||||
if g:
|
||||
genres.update(g)
|
||||
if f:
|
||||
@@ -1853,6 +1872,8 @@ class DBRomsHandler(DBBaseHandler):
|
||||
regions.update(rg)
|
||||
if lg:
|
||||
languages.update(lg)
|
||||
if tg:
|
||||
tags.update(tg)
|
||||
platforms.add(pid)
|
||||
|
||||
return {
|
||||
@@ -1865,6 +1886,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
"player_counts": sorted(player_counts),
|
||||
"regions": sorted(regions),
|
||||
"languages": sorted(languages),
|
||||
"tags": sorted(tags),
|
||||
"platforms": sorted(platforms),
|
||||
}
|
||||
|
||||
@@ -1913,6 +1935,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
RomMetadata.player_count,
|
||||
Rom.regions,
|
||||
Rom.languages,
|
||||
Rom.tags,
|
||||
Rom.platform_id,
|
||||
)
|
||||
.select_from(Rom)
|
||||
@@ -1943,6 +1966,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
RomMetadata.player_count,
|
||||
Rom.regions,
|
||||
Rom.languages,
|
||||
Rom.tags,
|
||||
Rom.platform_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +128,51 @@ def test_get_roms_filter_by_metadata_providers(
|
||||
assert {item["id"] for item in body["items"]} == {rom.id}
|
||||
|
||||
|
||||
def test_get_roms_filter_by_tags(
|
||||
client: TestClient, access_token: str, rom: Rom, platform: Platform
|
||||
):
|
||||
rom_proto = db_rom_handler.add_rom(
|
||||
Rom(
|
||||
platform_id=platform.id,
|
||||
name="rom_proto",
|
||||
slug="rom_proto",
|
||||
fs_name="rom_proto.zip",
|
||||
fs_name_no_tags="rom_proto",
|
||||
fs_name_no_ext="rom_proto",
|
||||
fs_extension="zip",
|
||||
fs_path=f"{platform.slug}/roms",
|
||||
tags=["Proto"],
|
||||
)
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/roms",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
params={"platform_id": platform.id, "tags": ["Proto"]},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
body = response.json()
|
||||
assert {item["id"] for item in body["items"]} == {rom_proto.id}
|
||||
# The selectable tag list is surfaced through filter_values.
|
||||
assert "Proto" in body["filter_values"]["tags"]
|
||||
|
||||
# "none" logic returns the ROMs not carrying the selected tag.
|
||||
response = client.get(
|
||||
"/api/roms",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
params={
|
||||
"platform_id": platform.id,
|
||||
"tags": ["Proto"],
|
||||
"tags_logic": "none",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
body = response.json()
|
||||
assert {item["id"] for item in body["items"]} == {rom.id}
|
||||
|
||||
|
||||
def test_get_all_roms_with_files(
|
||||
client: TestClient, access_token: str, rom: Rom, platform: Platform
|
||||
):
|
||||
|
||||
@@ -42,3 +42,23 @@ def test_get_smart_collection_roms_passes_metadata_providers(admin_user: User, m
|
||||
|
||||
assert get_roms_scalar.call_args.kwargs["metadata_providers"] == ["igdb", "moby"]
|
||||
assert get_roms_scalar.call_args.kwargs["metadata_providers_logic"] == "all"
|
||||
|
||||
|
||||
def test_get_smart_collection_roms_passes_tags(admin_user: User, mocker):
|
||||
get_roms_scalar = mocker.patch("handler.database.db_rom_handler.get_roms_scalar")
|
||||
smart_collection = SmartCollection(
|
||||
name="Prototypes and betas",
|
||||
description="",
|
||||
user_id=admin_user.id,
|
||||
filter_criteria={
|
||||
"tags": ["Proto", "Beta"],
|
||||
"tags_logic": "any",
|
||||
},
|
||||
)
|
||||
|
||||
db_collection_handler.get_smart_collection_roms(
|
||||
smart_collection, user_id=admin_user.id
|
||||
)
|
||||
|
||||
assert get_roms_scalar.call_args.kwargs["tags"] == ["Proto", "Beta"]
|
||||
assert get_roms_scalar.call_args.kwargs["tags_logic"] == "any"
|
||||
|
||||
@@ -954,3 +954,60 @@ def test_filter_by_metadata_providers_unknown_value_is_ignored(
|
||||
metadata_providers=["igdb", "bogus"]
|
||||
)
|
||||
assert {r.id for r in known_and_unknown} == {rom_igdb.id}
|
||||
|
||||
|
||||
def _add_rom_with_tags(platform: Platform, slug: str, tags: list[str]) -> Rom:
|
||||
return db_rom_handler.add_rom(
|
||||
Rom(
|
||||
platform_id=platform.id,
|
||||
name=slug,
|
||||
slug=slug,
|
||||
fs_name=f"{slug}.zip",
|
||||
fs_name_no_tags=slug,
|
||||
fs_name_no_ext=slug,
|
||||
fs_extension="zip",
|
||||
fs_path=f"{platform.slug}/roms",
|
||||
tags=tags,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_filter_by_tags(rom: Rom, platform: Platform):
|
||||
# `rom` fixture has no tags (untagged).
|
||||
rom_proto = _add_rom_with_tags(platform, "rom_proto", ["Proto"])
|
||||
rom_beta = _add_rom_with_tags(platform, "rom_beta", ["Beta"])
|
||||
rom_both = _add_rom_with_tags(platform, "rom_both", ["Proto", "Beta"])
|
||||
|
||||
# "any" (OR): carries at least one of the selected tags.
|
||||
any_proto = db_rom_handler.get_roms_scalar(tags=["Proto"])
|
||||
assert {r.id for r in any_proto} == {rom_proto.id, rom_both.id}
|
||||
|
||||
any_either = db_rom_handler.get_roms_scalar(
|
||||
tags=["Proto", "Beta"], tags_logic="any"
|
||||
)
|
||||
assert {r.id for r in any_either} == {rom_proto.id, rom_beta.id, rom_both.id}
|
||||
|
||||
# "all" (AND): carries every selected tag.
|
||||
all_both = db_rom_handler.get_roms_scalar(tags=["Proto", "Beta"], tags_logic="all")
|
||||
assert {r.id for r in all_both} == {rom_both.id}
|
||||
|
||||
# "none" (NOT): carries none of the selected tags.
|
||||
none_proto = db_rom_handler.get_roms_scalar(tags=["Proto"], tags_logic="none")
|
||||
assert {r.id for r in none_proto} == {rom.id, rom_beta.id}
|
||||
|
||||
|
||||
def test_filter_by_tags_unknown_value_returns_no_matches(rom: Rom, platform: Platform):
|
||||
"""A tag that no ROM carries simply matches nothing under "any" logic
|
||||
(free-form text match), rather than erroring."""
|
||||
_add_rom_with_tags(platform, "rom_proto", ["Proto"])
|
||||
|
||||
only_unknown = db_rom_handler.get_roms_scalar(tags=["Nonexistent"])
|
||||
assert list(only_unknown) == []
|
||||
|
||||
|
||||
def test_get_rom_filters_includes_tags(rom: Rom, platform: Platform):
|
||||
_add_rom_with_tags(platform, "rom_proto", ["Proto"])
|
||||
_add_rom_with_tags(platform, "rom_beta", ["Beta", "Demo"])
|
||||
|
||||
filters = db_rom_handler.get_rom_filters()
|
||||
assert filters["tags"] == ["Beta", "Demo", "Proto"]
|
||||
|
||||
@@ -12,6 +12,7 @@ export type RomFiltersDict = {
|
||||
player_counts: Array<string>;
|
||||
regions: Array<string>;
|
||||
languages: Array<string>;
|
||||
tags: Array<string>;
|
||||
platforms: Array<number>;
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ const {
|
||||
playerCountsLogic,
|
||||
selectedMetadataProviders,
|
||||
metadataProvidersLogic,
|
||||
filterTags,
|
||||
selectedTags,
|
||||
tagsLogic,
|
||||
} = storeToRefs(galleryFilterStore);
|
||||
|
||||
// Provider options are a fixed registry (not data-derived like the other
|
||||
@@ -175,6 +178,8 @@ const onFilterChange = debounce(
|
||||
selectedMetadataProviders.value.length > 0
|
||||
? metadataProvidersLogic.value
|
||||
: null,
|
||||
tags: selectedTags.value.length > 0 ? selectedTags.value.join(",") : null,
|
||||
tagsLogic: selectedTags.value.length > 0 ? tagsLogic.value : null,
|
||||
}).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
@@ -256,6 +261,14 @@ const filters = [
|
||||
setLogic: (logic: FilterLogicOperator) =>
|
||||
galleryFilterStore.setLanguagesLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.tag"),
|
||||
selected: selectedTags,
|
||||
items: filterTags,
|
||||
logic: tagsLogic,
|
||||
setLogic: (logic: FilterLogicOperator) =>
|
||||
galleryFilterStore.setTagsLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.player-count"),
|
||||
selected: selectedPlayerCounts,
|
||||
@@ -322,6 +335,8 @@ onMounted(async () => {
|
||||
playerCountsLogic: urlPlayerCountsLogic,
|
||||
metadataProviders: urlMetadataProviders,
|
||||
metadataProvidersLogic: urlMetadataProvidersLogic,
|
||||
tags: urlTags,
|
||||
tagsLogic: urlTagsLogic,
|
||||
} = router.currentRoute.value.query;
|
||||
|
||||
// Check for query params to set filters
|
||||
@@ -516,6 +531,14 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (urlTags !== undefined) {
|
||||
const tags = (urlTags as string).split(",").filter((tag) => tag.trim());
|
||||
galleryFilterStore.setSelectedFilterTags(tags);
|
||||
if (urlTagsLogic !== undefined) {
|
||||
galleryFilterStore.setTagsLogic(urlTagsLogic as FilterLogicOperator);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if search term is set in the URL (empty string is ok)
|
||||
const freshSearch = urlSearch !== undefined && urlSearch !== searchTerm.value;
|
||||
if (freshSearch) {
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Филтри",
|
||||
"show": "Покажи",
|
||||
"tags": "Тагове",
|
||||
"tag": "Таг",
|
||||
"show-all": "Покажи всички",
|
||||
"show-matched-only": "Покажи само разпознати ROM-ове",
|
||||
"show-unmatched-only": "Покажи само неразпознати ROM-ове",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filtry",
|
||||
"show": "Zobrazit",
|
||||
"tags": "Štítky",
|
||||
"tag": "Štítek",
|
||||
"show-all": "Zobrazit vše",
|
||||
"show-matched-only": "Zobrazit pouze spárované ROMy",
|
||||
"show-unmatched-only": "Zobrazit pouze nespárované ROMy",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filter",
|
||||
"show": "Anzeigen",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"show-all": "Alle anzeigen",
|
||||
"show-matched-only": "Nur zugeordnete ROMs anzeigen",
|
||||
"show-unmatched-only": "Nur nicht zugeordnete ROMs anzeigen",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filters",
|
||||
"show": "Show",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"show-all": "Show all",
|
||||
"show-matched-only": "Show matched ROMs only",
|
||||
"show-unmatched-only": "Show unmatched ROMs only",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filters",
|
||||
"show": "Show",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"show-all": "Show all",
|
||||
"show-matched-only": "Show matched ROMs only",
|
||||
"show-unmatched-only": "Show unmatched ROMs only",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"show-matched-only": "Solo emparejados",
|
||||
"show-unmatched-only": "Solo sin emparejar",
|
||||
"tags": "Etiquetas",
|
||||
"tag": "Etiqueta",
|
||||
"delete-platform-hint": "Elimina la plataforma y sus entradas de ROM. Los archivos en disco NO se eliminan.",
|
||||
"edit-platform": "Editar plataforma",
|
||||
"rom-count": "{n} ROM | {n} ROMs",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filtres",
|
||||
"show": "Afficher",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"show-all": "Tout afficher",
|
||||
"show-matched-only": "Afficher uniquement les ROMs identifiées",
|
||||
"show-unmatched-only": "Afficher uniquement les ROMs non identifiées",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Szűrők",
|
||||
"show": "Megjelenítés",
|
||||
"tags": "Címkék",
|
||||
"tag": "Címke",
|
||||
"show-all": "Összes megjelenítése",
|
||||
"show-matched-only": "Csak egyeztetett ROM-ok megjelenítése",
|
||||
"show-unmatched-only": "Csak nem egyeztetett ROM-ok megjelenítése",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filtri",
|
||||
"show": "Mostra",
|
||||
"tags": "Tag",
|
||||
"tag": "Tag",
|
||||
"show-all": "Mostra tutto",
|
||||
"show-matched-only": "Mostra solo ROM con corrispondenza",
|
||||
"show-unmatched-only": "Mostra solo ROM senza corrispondenza",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "フィルタ",
|
||||
"show": "表示",
|
||||
"tags": "タグ",
|
||||
"tag": "タグ",
|
||||
"show-all": "すべて表示",
|
||||
"show-matched-only": "マッチしたROMのみ表示",
|
||||
"show-unmatched-only": "マッチしていないROMのみ表示",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "필터",
|
||||
"show": "표시",
|
||||
"tags": "태그",
|
||||
"tag": "태그",
|
||||
"show-all": "모두 표시",
|
||||
"show-matched-only": "일치된 ROM만 표시",
|
||||
"show-unmatched-only": "일치되지 않은 ROM만 표시",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filtry",
|
||||
"show": "Pokaż",
|
||||
"tags": "Tagi",
|
||||
"tag": "Tag",
|
||||
"show-all": "Pokaż wszystkie",
|
||||
"show-matched-only": "Pokaż tylko dopasowane ROM-y",
|
||||
"show-unmatched-only": "Pokaż tylko niedopasowane ROM-y",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filtros",
|
||||
"show": "Mostrar",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"show-all": "Mostrar todos",
|
||||
"show-matched-only": "Mostrar apenas ROMs correspondentes",
|
||||
"show-unmatched-only": "Mostrar apenas ROMs sem correspondência",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filtre",
|
||||
"show": "Afișează",
|
||||
"tags": "Etichete",
|
||||
"tag": "Etichetă",
|
||||
"show-all": "Afișează toate",
|
||||
"show-matched-only": "Afișează doar ROM-urile identificate",
|
||||
"show-unmatched-only": "Afișează doar ROM-urile neidentificate",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Фильтры",
|
||||
"show": "Показать",
|
||||
"tags": "Теги",
|
||||
"tag": "Тег",
|
||||
"show-all": "Показать все",
|
||||
"show-matched-only": "Показать только сопоставленные ROM-ы",
|
||||
"show-unmatched-only": "Показать только несопоставленные ROM-ы",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "Filtreler",
|
||||
"show": "Göster",
|
||||
"tags": "Etiketler",
|
||||
"tag": "Etiket",
|
||||
"show-all": "Tümünü göster",
|
||||
"show-matched-only": "Yalnızca eşleşen ROM'ları göster",
|
||||
"show-unmatched-only": "Yalnızca eşleşmeyen ROM'ları göster",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "筛选器",
|
||||
"show": "显示",
|
||||
"tags": "标签",
|
||||
"tag": "标签",
|
||||
"show-all": "显示全部",
|
||||
"show-matched-only": "仅显示已匹配的 ROM",
|
||||
"show-unmatched-only": "仅显示未匹配的 ROM",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"filters": "篩選器",
|
||||
"show": "顯示",
|
||||
"tags": "標籤",
|
||||
"tag": "標籤",
|
||||
"show-all": "顯示全部",
|
||||
"show-matched-only": "僅顯示已匹配的 ROM",
|
||||
"show-unmatched-only": "僅顯示未匹配的 ROM",
|
||||
|
||||
@@ -166,6 +166,7 @@ export interface GetRomsParams {
|
||||
selectedLanguages?: string[] | null;
|
||||
selectedPlayerCounts?: string[] | null;
|
||||
selectedMetadataProviders?: string[] | null;
|
||||
selectedTags?: string[] | null;
|
||||
selectedStatuses?: string[] | null;
|
||||
// Logic operators for multi-value filters
|
||||
genresLogic?: string | null;
|
||||
@@ -178,6 +179,7 @@ export interface GetRomsParams {
|
||||
statusesLogic?: string | null;
|
||||
playerCountsLogic?: string | null;
|
||||
metadataProvidersLogic?: string | null;
|
||||
tagsLogic?: string | null;
|
||||
// Cancellation: pass an AbortSignal to let the caller abort an
|
||||
// in-flight request (e.g. search-typing → previous query aborted,
|
||||
// gallery-context switch → previous platform's windows aborted).
|
||||
@@ -211,6 +213,7 @@ async function getRoms({
|
||||
selectedLanguages = null,
|
||||
selectedPlayerCounts = null,
|
||||
selectedMetadataProviders = null,
|
||||
selectedTags = null,
|
||||
selectedStatuses = null,
|
||||
// Logic operators
|
||||
genresLogic = null,
|
||||
@@ -223,6 +226,7 @@ async function getRoms({
|
||||
statusesLogic = null,
|
||||
playerCountsLogic = null,
|
||||
metadataProvidersLogic = null,
|
||||
tagsLogic = null,
|
||||
signal = undefined,
|
||||
}: GetRomsParams) {
|
||||
const params = {
|
||||
@@ -275,6 +279,7 @@ async function getRoms({
|
||||
selectedMetadataProviders && selectedMetadataProviders.length > 0
|
||||
? selectedMetadataProviders
|
||||
: undefined,
|
||||
tags: selectedTags && selectedTags.length > 0 ? selectedTags : undefined,
|
||||
// Logic operators
|
||||
genres_logic:
|
||||
selectedGenres && selectedGenres.length > 0
|
||||
@@ -316,6 +321,8 @@ async function getRoms({
|
||||
selectedMetadataProviders && selectedMetadataProviders.length > 0
|
||||
? metadataProvidersLogic || "any"
|
||||
: undefined,
|
||||
tags_logic:
|
||||
selectedTags && selectedTags.length > 0 ? tagsLogic || "any" : undefined,
|
||||
...(filterMatched !== null ? { matched: filterMatched } : {}),
|
||||
...(filterFavorites !== null ? { favorite: filterFavorites } : {}),
|
||||
...(filterDuplicates !== null ? { duplicate: filterDuplicates } : {}),
|
||||
|
||||
Vendored
+8
@@ -83,6 +83,10 @@ class CachedApiService {
|
||||
params.selectedMetadataProviders.length > 0
|
||||
? params.selectedMetadataProviders
|
||||
: undefined,
|
||||
tags:
|
||||
params.selectedTags && params.selectedTags.length > 0
|
||||
? params.selectedTags
|
||||
: undefined,
|
||||
// Logic operators
|
||||
genres_logic:
|
||||
params.selectedGenres && params.selectedGenres.length > 0
|
||||
@@ -125,6 +129,10 @@ class CachedApiService {
|
||||
params.selectedMetadataProviders.length > 0
|
||||
? params.metadataProvidersLogic || "any"
|
||||
: undefined,
|
||||
tags_logic:
|
||||
params.selectedTags && params.selectedTags.length > 0
|
||||
? params.tagsLogic || "any"
|
||||
: undefined,
|
||||
...(params.filterMatched !== null
|
||||
? { matched: params.filterMatched }
|
||||
: {}),
|
||||
|
||||
@@ -12,7 +12,8 @@ export type FilterType =
|
||||
| "regions"
|
||||
| "languages"
|
||||
| "playerCounts"
|
||||
| "metadataProviders";
|
||||
| "metadataProviders"
|
||||
| "tags";
|
||||
|
||||
export type FilterLogicOperator = "any" | "all" | "none";
|
||||
|
||||
@@ -33,6 +34,7 @@ const buildDefaultFilterState = () => ({
|
||||
filterRegions: [] as string[],
|
||||
filterLanguages: [] as string[],
|
||||
filterPlayerCounts: [] as string[],
|
||||
filterTags: [] as string[],
|
||||
filterStatuses: Object.keys(romStatusMap),
|
||||
filterMatched: null as boolean | null, // null = all, true = matched, false = unmatched
|
||||
filterFavorites: null as boolean | null, // null = all, true = favorites, false = not favorites
|
||||
@@ -52,6 +54,7 @@ const buildDefaultFilterState = () => ({
|
||||
selectedLanguages: [] as string[],
|
||||
selectedPlayerCounts: [] as string[],
|
||||
selectedMetadataProviders: [] as string[],
|
||||
selectedTags: [] as string[],
|
||||
selectedStatuses: [] as string[],
|
||||
// Logic operators for multi-select filters
|
||||
genresLogic: "any" as FilterLogicOperator,
|
||||
@@ -64,6 +67,7 @@ const buildDefaultFilterState = () => ({
|
||||
statusesLogic: "any" as FilterLogicOperator,
|
||||
playerCountsLogic: "any" as FilterLogicOperator,
|
||||
metadataProvidersLogic: "any" as FilterLogicOperator,
|
||||
tagsLogic: "any" as FilterLogicOperator,
|
||||
});
|
||||
|
||||
export default defineStore("galleryFilter", {
|
||||
@@ -100,6 +104,9 @@ export default defineStore("galleryFilter", {
|
||||
setFilterPlayerCounts(playerCounts: string[]) {
|
||||
this.filterPlayerCounts = playerCounts;
|
||||
},
|
||||
setFilterTags(tags: string[]) {
|
||||
this.filterTags = tags;
|
||||
},
|
||||
setSelectedFilterPlatform(platform: Platform) {
|
||||
this.selectedPlatform = platform
|
||||
? this.filterPlatforms.find((p) => p.id === platform.id) || null
|
||||
@@ -164,6 +171,12 @@ export default defineStore("galleryFilter", {
|
||||
setMetadataProvidersLogic(logic: FilterLogicOperator) {
|
||||
this.metadataProvidersLogic = logic;
|
||||
},
|
||||
setSelectedFilterTags(tags: string[]) {
|
||||
this.selectedTags = tags;
|
||||
},
|
||||
setTagsLogic(logic: FilterLogicOperator) {
|
||||
this.tagsLogic = logic;
|
||||
},
|
||||
setSelectedFilterStatuses(statuses: string[]) {
|
||||
this.selectedStatuses = statuses;
|
||||
},
|
||||
@@ -365,6 +378,7 @@ export default defineStore("galleryFilter", {
|
||||
this.selectedLanguages.length > 0 ||
|
||||
this.selectedPlayerCounts.length > 0 ||
|
||||
this.selectedMetadataProviders.length > 0 ||
|
||||
this.selectedTags.length > 0 ||
|
||||
this.selectedStatuses.length > 0,
|
||||
);
|
||||
},
|
||||
@@ -383,6 +397,7 @@ export default defineStore("galleryFilter", {
|
||||
this.selectedLanguages = [];
|
||||
this.selectedPlayerCounts = [];
|
||||
this.selectedMetadataProviders = [];
|
||||
this.selectedTags = [];
|
||||
this.selectedStatuses = [];
|
||||
this.filterMatched = null;
|
||||
this.filterFavorites = null;
|
||||
@@ -402,6 +417,7 @@ export default defineStore("galleryFilter", {
|
||||
this.statusesLogic = "any";
|
||||
this.playerCountsLogic = "any";
|
||||
this.metadataProvidersLogic = "any";
|
||||
this.tagsLogic = "any";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -166,6 +166,7 @@ export default defineStore("roms", {
|
||||
selectedLanguages: galleryFilter.selectedLanguages,
|
||||
selectedPlayerCounts: galleryFilter.selectedPlayerCounts,
|
||||
selectedMetadataProviders: galleryFilter.selectedMetadataProviders,
|
||||
selectedTags: galleryFilter.selectedTags,
|
||||
selectedStatuses: galleryFilter.selectedStatuses,
|
||||
// Logic operators
|
||||
genresLogic: galleryFilter.genresLogic,
|
||||
@@ -178,6 +179,7 @@ export default defineStore("roms", {
|
||||
statusesLogic: galleryFilter.statusesLogic,
|
||||
playerCountsLogic: galleryFilter.playerCountsLogic,
|
||||
metadataProvidersLogic: galleryFilter.metadataProvidersLogic,
|
||||
tagsLogic: galleryFilter.tagsLogic,
|
||||
};
|
||||
return params;
|
||||
},
|
||||
@@ -221,6 +223,7 @@ export default defineStore("roms", {
|
||||
galleryFilter.setFilterRegions(filter_values.regions);
|
||||
galleryFilter.setFilterLanguages(filter_values.languages);
|
||||
galleryFilter.setFilterPlayerCounts(filter_values.player_counts);
|
||||
galleryFilter.setFilterTags(filter_values.tags);
|
||||
}
|
||||
},
|
||||
/** @deprecated v2: use `useGalleryRoms().fetchWindowAt(position)` for
|
||||
|
||||
@@ -110,6 +110,8 @@ const openHandler = () => {
|
||||
playerCountsLogic: galleryFilter.playerCountsLogic,
|
||||
selectedMetadataProviders: galleryFilter.selectedMetadataProviders,
|
||||
metadataProvidersLogic: galleryFilter.metadataProvidersLogic,
|
||||
selectedTags: galleryFilter.selectedTags,
|
||||
tagsLogic: galleryFilter.tagsLogic,
|
||||
selectedStatuses: galleryFilter.selectedStatuses,
|
||||
statusesLogic: galleryFilter.statusesLogic,
|
||||
},
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
// true → positive, false → negative.
|
||||
// • Optional platform multi-select (only on Search / Collection views
|
||||
// where you can mix platforms).
|
||||
// • 10 multi-select filter groups (genres / franchises / collections /
|
||||
// companies / age-ratings / regions / languages / player-counts /
|
||||
// metadata-providers / statuses) — each paired with an AND/OR/NONE
|
||||
// logic toggle.
|
||||
// • 11 multi-select filter groups (genres / franchises / collections /
|
||||
// companies / age-ratings / regions / languages / tags /
|
||||
// player-counts / metadata-providers / statuses) — each paired with
|
||||
// an AND/OR/NONE logic toggle.
|
||||
// • Reset button at the bottom.
|
||||
//
|
||||
// Apply is implicit — the URL composable + galleryRoms watcher refresh
|
||||
@@ -90,6 +90,9 @@ const {
|
||||
playerCountsLogic,
|
||||
selectedMetadataProviders,
|
||||
metadataProvidersLogic,
|
||||
filterTags,
|
||||
selectedTags,
|
||||
tagsLogic,
|
||||
filterStatuses,
|
||||
selectedStatuses,
|
||||
statusesLogic,
|
||||
@@ -302,6 +305,14 @@ const multiSections = computed<MultiConfig[]>(() => [
|
||||
logic: languagesLogic,
|
||||
setLogic: (l) => filter.setLanguagesLogic(l),
|
||||
},
|
||||
{
|
||||
label: t("platform.tag"),
|
||||
icon: "mdi-tag-outline",
|
||||
items: filterTags,
|
||||
selected: selectedTags,
|
||||
logic: tagsLogic,
|
||||
setLogic: (l) => filter.setTagsLogic(l),
|
||||
},
|
||||
{
|
||||
label: t("platform.player-count"),
|
||||
icon: "mdi-account-group",
|
||||
|
||||
@@ -166,6 +166,7 @@ const {
|
||||
selectedLanguages,
|
||||
selectedPlayerCounts,
|
||||
selectedMetadataProviders,
|
||||
selectedTags,
|
||||
selectedStatuses,
|
||||
} = storeToRefs(galleryFilterStore);
|
||||
|
||||
@@ -196,6 +197,7 @@ const filterActiveCount = computed(() => {
|
||||
selectedLanguages,
|
||||
selectedPlayerCounts,
|
||||
selectedMetadataProviders,
|
||||
selectedTags,
|
||||
selectedStatuses,
|
||||
]) {
|
||||
if (arr.value.length > 0) n += 1;
|
||||
@@ -227,6 +229,7 @@ watch(
|
||||
selectedLanguages,
|
||||
selectedPlayerCounts,
|
||||
selectedMetadataProviders,
|
||||
selectedTags,
|
||||
selectedStatuses,
|
||||
],
|
||||
() => {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
// ?statuses=…&statusesLogic=…
|
||||
// ?playerCounts=…&playerCountsLogic=…
|
||||
// ?metadataProviders=…&metadataProvidersLogic=…
|
||||
// ?tags=…&tagsLogic=…
|
||||
//
|
||||
// Direction notes:
|
||||
// * URL → store fires on every `route.query` change (browser back /
|
||||
@@ -112,6 +113,7 @@ export function useGalleryFilterUrl() {
|
||||
selectedLanguages,
|
||||
selectedPlayerCounts,
|
||||
selectedMetadataProviders,
|
||||
selectedTags,
|
||||
selectedStatuses,
|
||||
genresLogic,
|
||||
franchisesLogic,
|
||||
@@ -122,6 +124,7 @@ export function useGalleryFilterUrl() {
|
||||
languagesLogic,
|
||||
playerCountsLogic,
|
||||
metadataProvidersLogic,
|
||||
tagsLogic,
|
||||
statusesLogic,
|
||||
} = storeToRefs(filter);
|
||||
|
||||
@@ -162,6 +165,8 @@ export function useGalleryFilterUrl() {
|
||||
playerCountsLogic: qLogic(q.playerCountsLogic),
|
||||
metadataProviders: qList(q.metadataProviders),
|
||||
metadataProvidersLogic: qLogic(q.metadataProvidersLogic),
|
||||
tags: qList(q.tags),
|
||||
tagsLogic: qLogic(q.tagsLogic),
|
||||
statuses: qList(q.statuses),
|
||||
statusesLogic: qLogic(q.statusesLogic),
|
||||
};
|
||||
@@ -251,6 +256,11 @@ export function useGalleryFilterUrl() {
|
||||
)
|
||||
filter.setMetadataProvidersLogic(url.metadataProvidersLogic);
|
||||
|
||||
if (!eqStrArr(url.tags, selectedTags.value))
|
||||
filter.setSelectedFilterTags(url.tags);
|
||||
if (url.tagsLogic && url.tagsLogic !== tagsLogic.value)
|
||||
filter.setTagsLogic(url.tagsLogic);
|
||||
|
||||
if (!eqStrArr(url.statuses, selectedStatuses.value))
|
||||
filter.setSelectedFilterStatuses(url.statuses);
|
||||
if (url.statusesLogic && url.statusesLogic !== statusesLogic.value)
|
||||
@@ -359,6 +369,11 @@ export function useGalleryFilterUrl() {
|
||||
? metadataProvidersLogic.value
|
||||
: null,
|
||||
);
|
||||
setList("tags", selectedTags.value);
|
||||
setOrDelete(
|
||||
"tagsLogic",
|
||||
selectedTags.value.length > 0 ? tagsLogic.value : null,
|
||||
);
|
||||
setList("statuses", selectedStatuses.value);
|
||||
setOrDelete(
|
||||
"statusesLogic",
|
||||
@@ -414,6 +429,8 @@ export function useGalleryFilterUrl() {
|
||||
playerCountsLogic,
|
||||
selectedMetadataProviders,
|
||||
metadataProvidersLogic,
|
||||
selectedTags,
|
||||
tagsLogic,
|
||||
selectedStatuses,
|
||||
statusesLogic,
|
||||
],
|
||||
|
||||
@@ -308,6 +308,7 @@ export default defineStore("v2GalleryRoms", {
|
||||
selectedLanguages: galleryFilter.selectedLanguages,
|
||||
selectedPlayerCounts: galleryFilter.selectedPlayerCounts,
|
||||
selectedMetadataProviders: galleryFilter.selectedMetadataProviders,
|
||||
selectedTags: galleryFilter.selectedTags,
|
||||
selectedStatuses: galleryFilter.selectedStatuses,
|
||||
genresLogic: galleryFilter.genresLogic,
|
||||
franchisesLogic: galleryFilter.franchisesLogic,
|
||||
@@ -319,6 +320,7 @@ export default defineStore("v2GalleryRoms", {
|
||||
statusesLogic: galleryFilter.statusesLogic,
|
||||
playerCountsLogic: galleryFilter.playerCountsLogic,
|
||||
metadataProvidersLogic: galleryFilter.metadataProvidersLogic,
|
||||
tagsLogic: galleryFilter.tagsLogic,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -352,6 +354,7 @@ export default defineStore("v2GalleryRoms", {
|
||||
galleryFilter.setFilterRegions(data.filter_values.regions);
|
||||
galleryFilter.setFilterLanguages(data.filter_values.languages);
|
||||
galleryFilter.setFilterPlayerCounts(data.filter_values.player_counts);
|
||||
galleryFilter.setFilterTags(data.filter_values.tags);
|
||||
}
|
||||
this.metadataLoaded = true;
|
||||
},
|
||||
|
||||
@@ -41,6 +41,8 @@ function emptySnapshot(): GalleryFilterSnapshot {
|
||||
playerCountsLogic: "any",
|
||||
selectedMetadataProviders: [],
|
||||
metadataProvidersLogic: "any",
|
||||
selectedTags: [],
|
||||
tagsLogic: "any",
|
||||
selectedStatuses: [],
|
||||
statusesLogic: "any",
|
||||
};
|
||||
@@ -77,3 +79,35 @@ describe("buildSmartFilterCriteria — metadata providers", () => {
|
||||
expect(row?.logic).toBe("any");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSmartFilterCriteria — tags", () => {
|
||||
it("serializes selected tags with their logic operator", () => {
|
||||
const out = buildSmartFilterCriteria({
|
||||
...emptySnapshot(),
|
||||
selectedTags: ["Proto", "Beta"],
|
||||
tagsLogic: "all",
|
||||
});
|
||||
|
||||
expect(out.tags).toEqual(["Proto", "Beta"]);
|
||||
expect(out.tags_logic).toBe("all");
|
||||
});
|
||||
|
||||
it("omits the keys entirely when no tag is selected", () => {
|
||||
const out = buildSmartFilterCriteria(emptySnapshot());
|
||||
|
||||
expect(out.tags).toBeUndefined();
|
||||
expect(out.tags_logic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces tags in the human-readable summary", () => {
|
||||
const rows = summarizeSmartFilterCriteria(
|
||||
{ tags: ["Proto"], tags_logic: "any" },
|
||||
tStub,
|
||||
);
|
||||
|
||||
const row = rows.find((r) => r.key === "tags");
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.values).toEqual(["Proto"]);
|
||||
expect(row?.logic).toBe("any");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface SmartFilterCriteria {
|
||||
player_counts_logic?: FilterLogic;
|
||||
metadata_providers?: string[];
|
||||
metadata_providers_logic?: FilterLogic;
|
||||
tags?: string[];
|
||||
tags_logic?: FilterLogic;
|
||||
selected_status?: string[];
|
||||
statuses_logic?: FilterLogic;
|
||||
}
|
||||
@@ -87,6 +89,8 @@ export interface GalleryFilterSnapshot {
|
||||
playerCountsLogic: FilterLogic;
|
||||
selectedMetadataProviders: string[];
|
||||
metadataProvidersLogic: FilterLogic;
|
||||
selectedTags: string[];
|
||||
tagsLogic: FilterLogic;
|
||||
selectedStatuses: string[];
|
||||
statusesLogic: FilterLogic;
|
||||
}
|
||||
@@ -180,6 +184,10 @@ export function buildSmartFilterCriteria(
|
||||
out.metadata_providers = snap.selectedMetadataProviders;
|
||||
out.metadata_providers_logic = snap.metadataProvidersLogic;
|
||||
}
|
||||
if (snap.selectedTags.length > 0) {
|
||||
out.tags = snap.selectedTags;
|
||||
out.tags_logic = snap.tagsLogic;
|
||||
}
|
||||
if (snap.selectedStatuses.length > 0) {
|
||||
out.selected_status = snap.selectedStatuses;
|
||||
out.statuses_logic = snap.statusesLogic;
|
||||
@@ -386,6 +394,14 @@ const FIELDS: FieldSpec[] = [
|
||||
defaultLabel: "Metadata providers",
|
||||
kind: "list",
|
||||
},
|
||||
{
|
||||
storage: "tags",
|
||||
logicStorage: "tags_logic",
|
||||
icon: "mdi-tag-outline",
|
||||
labelKey: "platform.tag",
|
||||
defaultLabel: "Tags",
|
||||
kind: "list",
|
||||
},
|
||||
{
|
||||
storage: "selected_status",
|
||||
logicStorage: "statuses_logic",
|
||||
|
||||
Reference in New Issue
Block a user