diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index daca1afd6..e119213c2 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -652,4 +652,5 @@ class RomFiltersDict(TypedDict): player_counts: list[str] regions: list[str] languages: list[str] + tags: list[str] platforms: list[int] diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index adeff788e..7c9b67bd3 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -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: diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index fca1e7d5e..4d56f0057 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -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"), diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 15533cd5f..49cf41eb8 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -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, ) diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py index d090570cb..419d3686f 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -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 ): diff --git a/backend/tests/handler/database/test_collections_handler.py b/backend/tests/handler/database/test_collections_handler.py index e4adb55bd..55c88e49e 100644 --- a/backend/tests/handler/database/test_collections_handler.py +++ b/backend/tests/handler/database/test_collections_handler.py @@ -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" diff --git a/backend/tests/handler/test_db_handler.py b/backend/tests/handler/test_db_handler.py index 09e2a50f1..29f4247a9 100644 --- a/backend/tests/handler/test_db_handler.py +++ b/backend/tests/handler/test_db_handler.py @@ -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"] diff --git a/frontend/src/__generated__/models/RomFiltersDict.ts b/frontend/src/__generated__/models/RomFiltersDict.ts index 16ccfccea..bc56d30b1 100644 --- a/frontend/src/__generated__/models/RomFiltersDict.ts +++ b/frontend/src/__generated__/models/RomFiltersDict.ts @@ -12,6 +12,7 @@ export type RomFiltersDict = { player_counts: Array; regions: Array; languages: Array; + tags: Array; platforms: Array; }; diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue index 8ff8856d3..6f40b494e 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue @@ -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) { diff --git a/frontend/src/locales/bg_BG/platform.json b/frontend/src/locales/bg_BG/platform.json index 6c49efc2f..befbcf409 100644 --- a/frontend/src/locales/bg_BG/platform.json +++ b/frontend/src/locales/bg_BG/platform.json @@ -76,6 +76,7 @@ "filters": "Филтри", "show": "Покажи", "tags": "Тагове", + "tag": "Таг", "show-all": "Покажи всички", "show-matched-only": "Покажи само разпознати ROM-ове", "show-unmatched-only": "Покажи само неразпознати ROM-ове", diff --git a/frontend/src/locales/cs_CZ/platform.json b/frontend/src/locales/cs_CZ/platform.json index de4cfa725..c375a33f2 100644 --- a/frontend/src/locales/cs_CZ/platform.json +++ b/frontend/src/locales/cs_CZ/platform.json @@ -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", diff --git a/frontend/src/locales/de_DE/platform.json b/frontend/src/locales/de_DE/platform.json index 474ec37a4..590504d19 100644 --- a/frontend/src/locales/de_DE/platform.json +++ b/frontend/src/locales/de_DE/platform.json @@ -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", diff --git a/frontend/src/locales/en_GB/platform.json b/frontend/src/locales/en_GB/platform.json index 1c4735fef..bd0df268c 100644 --- a/frontend/src/locales/en_GB/platform.json +++ b/frontend/src/locales/en_GB/platform.json @@ -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", diff --git a/frontend/src/locales/en_US/platform.json b/frontend/src/locales/en_US/platform.json index a95406eab..7cba79ce3 100644 --- a/frontend/src/locales/en_US/platform.json +++ b/frontend/src/locales/en_US/platform.json @@ -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", diff --git a/frontend/src/locales/es_ES/platform.json b/frontend/src/locales/es_ES/platform.json index 967b13bc1..77d7ea1b3 100644 --- a/frontend/src/locales/es_ES/platform.json +++ b/frontend/src/locales/es_ES/platform.json @@ -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", diff --git a/frontend/src/locales/fr_FR/platform.json b/frontend/src/locales/fr_FR/platform.json index 009110fcc..3b501b638 100644 --- a/frontend/src/locales/fr_FR/platform.json +++ b/frontend/src/locales/fr_FR/platform.json @@ -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", diff --git a/frontend/src/locales/hu_HU/platform.json b/frontend/src/locales/hu_HU/platform.json index 0cc02c337..81617ab6e 100644 --- a/frontend/src/locales/hu_HU/platform.json +++ b/frontend/src/locales/hu_HU/platform.json @@ -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", diff --git a/frontend/src/locales/it_IT/platform.json b/frontend/src/locales/it_IT/platform.json index 3acd7788d..8f0854cc5 100644 --- a/frontend/src/locales/it_IT/platform.json +++ b/frontend/src/locales/it_IT/platform.json @@ -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", diff --git a/frontend/src/locales/ja_JP/platform.json b/frontend/src/locales/ja_JP/platform.json index 66ff13162..c4793c4d9 100644 --- a/frontend/src/locales/ja_JP/platform.json +++ b/frontend/src/locales/ja_JP/platform.json @@ -76,6 +76,7 @@ "filters": "フィルタ", "show": "表示", "tags": "タグ", + "tag": "タグ", "show-all": "すべて表示", "show-matched-only": "マッチしたROMのみ表示", "show-unmatched-only": "マッチしていないROMのみ表示", diff --git a/frontend/src/locales/ko_KR/platform.json b/frontend/src/locales/ko_KR/platform.json index 6290f908e..0945dc486 100644 --- a/frontend/src/locales/ko_KR/platform.json +++ b/frontend/src/locales/ko_KR/platform.json @@ -76,6 +76,7 @@ "filters": "필터", "show": "표시", "tags": "태그", + "tag": "태그", "show-all": "모두 표시", "show-matched-only": "일치된 ROM만 표시", "show-unmatched-only": "일치되지 않은 ROM만 표시", diff --git a/frontend/src/locales/pl_PL/platform.json b/frontend/src/locales/pl_PL/platform.json index 448a81504..c3bb715a1 100644 --- a/frontend/src/locales/pl_PL/platform.json +++ b/frontend/src/locales/pl_PL/platform.json @@ -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", diff --git a/frontend/src/locales/pt_BR/platform.json b/frontend/src/locales/pt_BR/platform.json index 307f94d96..ae5c32a99 100644 --- a/frontend/src/locales/pt_BR/platform.json +++ b/frontend/src/locales/pt_BR/platform.json @@ -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", diff --git a/frontend/src/locales/ro_RO/platform.json b/frontend/src/locales/ro_RO/platform.json index 4c7d06d1e..8c960e230 100644 --- a/frontend/src/locales/ro_RO/platform.json +++ b/frontend/src/locales/ro_RO/platform.json @@ -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", diff --git a/frontend/src/locales/ru_RU/platform.json b/frontend/src/locales/ru_RU/platform.json index 54b282c43..3985af22e 100644 --- a/frontend/src/locales/ru_RU/platform.json +++ b/frontend/src/locales/ru_RU/platform.json @@ -76,6 +76,7 @@ "filters": "Фильтры", "show": "Показать", "tags": "Теги", + "tag": "Тег", "show-all": "Показать все", "show-matched-only": "Показать только сопоставленные ROM-ы", "show-unmatched-only": "Показать только несопоставленные ROM-ы", diff --git a/frontend/src/locales/tr_TR/platform.json b/frontend/src/locales/tr_TR/platform.json index fc181c29e..78d28a5b2 100644 --- a/frontend/src/locales/tr_TR/platform.json +++ b/frontend/src/locales/tr_TR/platform.json @@ -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", diff --git a/frontend/src/locales/zh_CN/platform.json b/frontend/src/locales/zh_CN/platform.json index 3cb4a70ff..514fd716a 100644 --- a/frontend/src/locales/zh_CN/platform.json +++ b/frontend/src/locales/zh_CN/platform.json @@ -76,6 +76,7 @@ "filters": "筛选器", "show": "显示", "tags": "标签", + "tag": "标签", "show-all": "显示全部", "show-matched-only": "仅显示已匹配的 ROM", "show-unmatched-only": "仅显示未匹配的 ROM", diff --git a/frontend/src/locales/zh_TW/platform.json b/frontend/src/locales/zh_TW/platform.json index ac94e10c8..3d4ee69f8 100644 --- a/frontend/src/locales/zh_TW/platform.json +++ b/frontend/src/locales/zh_TW/platform.json @@ -76,6 +76,7 @@ "filters": "篩選器", "show": "顯示", "tags": "標籤", + "tag": "標籤", "show-all": "顯示全部", "show-matched-only": "僅顯示已匹配的 ROM", "show-unmatched-only": "僅顯示未匹配的 ROM", diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index 01bde1107..22d39c32f 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -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 } : {}), diff --git a/frontend/src/services/cache/api.ts b/frontend/src/services/cache/api.ts index f5a234cfe..533de0e3a 100644 --- a/frontend/src/services/cache/api.ts +++ b/frontend/src/services/cache/api.ts @@ -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 } : {}), diff --git a/frontend/src/stores/galleryFilter.ts b/frontend/src/stores/galleryFilter.ts index 33a079916..96051617f 100644 --- a/frontend/src/stores/galleryFilter.ts +++ b/frontend/src/stores/galleryFilter.ts @@ -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"; }, }, }); diff --git a/frontend/src/stores/roms.ts b/frontend/src/stores/roms.ts index 511127257..7315c7479 100644 --- a/frontend/src/stores/roms.ts +++ b/frontend/src/stores/roms.ts @@ -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 diff --git a/frontend/src/v2/components/Dialogs/CreateSmartCollectionDialog.vue b/frontend/src/v2/components/Dialogs/CreateSmartCollectionDialog.vue index 549bce2c9..23db90ba1 100644 --- a/frontend/src/v2/components/Dialogs/CreateSmartCollectionDialog.vue +++ b/frontend/src/v2/components/Dialogs/CreateSmartCollectionDialog.vue @@ -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, }, diff --git a/frontend/src/v2/components/Gallery/FilterDrawer.vue b/frontend/src/v2/components/Gallery/FilterDrawer.vue index 6d6b34a73..160082ed5 100644 --- a/frontend/src/v2/components/Gallery/FilterDrawer.vue +++ b/frontend/src/v2/components/Gallery/FilterDrawer.vue @@ -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(() => [ 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", diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index 43e57ca85..4747cfa38 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -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, ], () => { diff --git a/frontend/src/v2/composables/useGalleryFilterUrl/index.ts b/frontend/src/v2/composables/useGalleryFilterUrl/index.ts index d126552a1..295752a9f 100644 --- a/frontend/src/v2/composables/useGalleryFilterUrl/index.ts +++ b/frontend/src/v2/composables/useGalleryFilterUrl/index.ts @@ -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, ], diff --git a/frontend/src/v2/stores/galleryRoms.ts b/frontend/src/v2/stores/galleryRoms.ts index 6607edb8e..8dc1aacaf 100644 --- a/frontend/src/v2/stores/galleryRoms.ts +++ b/frontend/src/v2/stores/galleryRoms.ts @@ -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; }, diff --git a/frontend/src/v2/utils/smartCollectionCriteria.test.ts b/frontend/src/v2/utils/smartCollectionCriteria.test.ts index 0d66c5834..fe17ea9c8 100644 --- a/frontend/src/v2/utils/smartCollectionCriteria.test.ts +++ b/frontend/src/v2/utils/smartCollectionCriteria.test.ts @@ -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"); + }); +}); diff --git a/frontend/src/v2/utils/smartCollectionCriteria.ts b/frontend/src/v2/utils/smartCollectionCriteria.ts index f25aaab2d..b687bc255 100644 --- a/frontend/src/v2/utils/smartCollectionCriteria.ts +++ b/frontend/src/v2/utils/smartCollectionCriteria.ts @@ -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",