diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 65bef8fd1..3a9316ab0 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -159,6 +159,7 @@ ManualMetadata = TypedDict( "age_ratings": list[str] | None, "first_release_date": int | None, "youtube_video_id": str | None, + "alternative_names": list[str] | None, }, total=False, ) diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 88dbc2bba..4b3b3c4f7 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -21,7 +21,14 @@ from fastapi import ( from fastapi import Path as PathVar from fastapi import Query, Request, UploadFile, status from fastapi.responses import Response -from pydantic import AfterValidator, BaseModel, Field, StringConstraints +from pydantic import ( + AfterValidator, + BaseModel, + Field, + StringConstraints, + TypeAdapter, +) +from pydantic import ValidationError as PydanticValidationError from sqlalchemy.exc import IntegrityError from starlette.responses import FileResponse @@ -37,6 +44,7 @@ from endpoints.responses.base import PAGE_QUERY, LimitOffsetPage, PageParams from endpoints.responses.recommendation import SimilarRomSchema from endpoints.responses.rom import ( DetailedRomSchema, + ManualMetadata, RomUserSchema, SimpleRomSchema, ) @@ -315,6 +323,8 @@ class RomUpdateForm(BaseModel): url_manual: str | None = None +MANUAL_METADATA_ADAPTER: Final = TypeAdapter(ManualMetadata) + # The provider ids the edit form sets; changing one rematches the rom. MATCH_ID_FIELDS: Final = tuple( f for f in RomUpdateForm.model_fields if f.endswith("_id") @@ -2065,7 +2075,15 @@ async def update_rom( if cleaned_data["steam_id"] and raw_steam_metadata is not None: cleaned_data["steam_metadata"] = raw_steam_metadata if raw_manual_metadata is not None: - cleaned_data["manual_metadata"] = raw_manual_metadata + try: + cleaned_data["manual_metadata"] = MANUAL_METADATA_ADAPTER.validate_python( + raw_manual_metadata + ) + except PydanticValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid manual metadata: {exc}", + ) from exc # Fetch metadata from external sources if ( diff --git a/backend/handler/metadata/hasheous_handler.py b/backend/handler/metadata/hasheous_handler.py index 9910039f3..7328b1e96 100644 --- a/backend/handler/metadata/hasheous_handler.py +++ b/backend/handler/metadata/hasheous_handler.py @@ -156,7 +156,15 @@ def extract_metadata_from_igdb_rom(rom: dict[str, Any]) -> IGDBMetadata: [rom.get("franchise.name", None)] + pydash.map_(rom.get("franchises", {}), "name") ), - "alternative_names": pydash.map_(rom.get("alternative_names", {}), "name"), + "alternative_names": pydash.uniq( + pydash.compact( + [ + rom.get("name"), + *pydash.map_(rom.get("alternative_names", {}), "name"), + *pydash.map_(rom.get("game_localizations", {}), "name"), + ] + ) + ), "collections": pydash.map_(rom.get("collections", {}), "name"), "game_modes": pydash.map_(rom.get("game_modes", {}), "name"), # Not in `expandColumns`, so the proxy returns bare ids with no names. @@ -476,7 +484,7 @@ class HasheousHandler(MetadataHandler): self.proxy_igdb_game_endpoint, params={ "Id": igdb_id, - "expandColumns": "age_ratings, alternative_names, collections, cover, dlcs, expanded_games, franchise, franchises, game_modes, genres, involved_companies, platforms, ports, remakes, screenshots, similar_games, videos", + "expandColumns": "age_ratings, alternative_names, collections, cover, dlcs, expanded_games, franchise, franchises, game_localizations, game_modes, genres, involved_companies, platforms, ports, remakes, screenshots, similar_games, videos", }, method="GET", ) diff --git a/backend/handler/metadata/igdb_handler.py b/backend/handler/metadata/igdb_handler.py index c30dcee9f..f894a6637 100644 --- a/backend/handler/metadata/igdb_handler.py +++ b/backend/handler/metadata/igdb_handler.py @@ -187,6 +187,7 @@ def extract_metadata_from_igdb_rom( ) -> IGDBMetadata: age_ratings = rom.get("age_ratings", []) alternative_names = rom.get("alternative_names", []) + game_localizations = rom.get("game_localizations", []) collections = rom.get("collections", []) dlcs = rom.get("dlcs", []) expanded_games = rom.get("expanded_games", []) @@ -210,6 +211,7 @@ def extract_metadata_from_igdb_rom( assert mark_expanded(franchise) assert mark_list_expanded(age_ratings) assert mark_list_expanded(alternative_names) + assert mark_list_expanded(game_localizations) assert mark_list_expanded(collections) assert mark_list_expanded(dlcs) assert mark_list_expanded(expanded_games) @@ -280,7 +282,17 @@ def extract_metadata_from_igdb_rom( ] ) ), - "alternative_names": _expanded_names(alternative_names), + # The canonical name too, so it stays searchable when a localized + # title is displayed. + "alternative_names": pydash.uniq( + pydash.compact( + [ + rom.get("name"), + *_expanded_names(alternative_names), + *_expanded_names(game_localizations), + ] + ) + ), "collections": _expanded_names(collections), "game_modes": _expanded_names(game_modes), "companies": [ diff --git a/backend/models/rom.py b/backend/models/rom.py index 01c1b3b20..6b792dc65 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -64,12 +64,17 @@ AUDIO_TAG_MAX_LENGTH = 512 # Max length for the binary identity columns (title id and save target). TITLE_ID_MAX_LENGTH = 100 -# (metadata column, key) of each provider's alternative titles, in precedence order. -ALTERNATIVE_NAME_SOURCES = ( +# (metadata column, key) of each provider's alternative titles. +PROVIDER_ALTERNATIVE_NAME_SOURCES = ( ("igdb_metadata", "alternative_names"), ("moby_metadata", "alternate_titles"), ("ss_metadata", "alternative_names"), ) +MANUAL_ALTERNATIVE_NAME_SOURCE = ("manual_metadata", "alternative_names") +ALTERNATIVE_NAME_SOURCES = ( + MANUAL_ALTERNATIVE_NAME_SOURCE, + *PROVIDER_ALTERNATIVE_NAME_SOURCES, +) # Limits on `RomUser.pinned_media`, a list of keys like `file:12` naming the # media shown on the user's overview. @@ -128,6 +133,33 @@ def fold_search_title(title: str) -> str: return " ".join(title.replace(SEARCH_TITLE_SEPARATOR, " ").split()).lower() +def source_titles(blob: Any, key: str) -> list[str]: + """The string titles under `key`, ignoring a malformed blob or entry.""" + names = blob.get(key) if isinstance(blob, dict) else None + if not isinstance(names, list): + return [] + return [title for title in names if isinstance(title, str)] + + +def resolve_alternative_names(metadata: Mapping[str, Any]) -> list[str]: + """The hand-set titles, or every provider's when none are set, each trimmed and + kept once however it is cased or spaced. + + Args: + metadata: Each `ALTERNATIVE_NAME_SOURCES` column's value, by column name. + """ + manual_column, manual_key = MANUAL_ALTERNATIVE_NAME_SOURCE + titles = source_titles(metadata.get(manual_column), manual_key) or [ + title + for column, key in PROVIDER_ALTERNATIVE_NAME_SOURCES + for title in source_titles(metadata.get(column), key) + ] + unique: dict[str, str] = {} + for title in titles: + unique.setdefault(fold_search_title(title), " ".join(title.split())) + return [title for folded, title in unique.items() if folded] + + def compute_search_titles(name: str | None, metadata: Mapping[str, Any]) -> str: """Precompute `Rom.search_titles`: the folded name, then each folded alias. @@ -135,14 +167,7 @@ def compute_search_titles(name: str | None, metadata: Mapping[str, Any]) -> str: metadata: Each `ALTERNATIVE_NAME_SOURCES` column's value, by column name. """ sep = SEARCH_TITLE_SEPARATOR - aliases = ( - title - for column, key in ALTERNATIVE_NAME_SOURCES - if isinstance(blob := metadata.get(column), dict) - and isinstance(names := blob.get(key), list) - for title in names - if isinstance(title, str) - ) + aliases = resolve_alternative_names(metadata) folded_name = fold_search_title(name or "") value = sep + folded_name + sep for alias in dict.fromkeys(fold_search_title(title) for title in aliases): @@ -1250,11 +1275,13 @@ class Rom(BaseModel): @property def alternative_names(self) -> list[str]: - for column, key in ALTERNATIVE_NAME_SOURCES: - names: list[str] | None = (getattr(self, column) or {}).get(key) - if names: - return names - return [] + """Every title `resolve_alternative_names` finds, but the displayed name.""" + folded_name = fold_search_title(self.name or "") + return [ + title + for title in resolve_alternative_names(alternative_name_metadata(self)) + if fold_search_title(title) != folded_name + ] @cached_property def merged_ra_metadata(self) -> dict[str, Any] | None: @@ -1383,12 +1410,14 @@ SEARCH_TITLE_COLUMNS = ("name", *(column for column, _ in ALTERNATIVE_NAME_SOURC ROM_VISIBILITY_COLUMNS = (Rom.id, Rom.platform_id, Rom.min_age) +def alternative_name_metadata(rom: Rom) -> dict[str, Any]: + """Each `ALTERNATIVE_NAME_SOURCES` column's current value, by column name.""" + return {column: getattr(rom, column) for column, _ in ALTERNATIVE_NAME_SOURCES} + + def rom_search_titles(rom: Rom) -> str: """`compute_search_titles` over the ROM's current name and metadata.""" - return compute_search_titles( - rom.name, - {column: getattr(rom, column) for column, _ in ALTERNATIVE_NAME_SOURCES}, - ) + return compute_search_titles(rom.name, alternative_name_metadata(rom)) @event.listens_for(Rom, "before_insert") diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py index b9c4592d8..1e37165e5 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -945,6 +945,60 @@ def test_update_rom_reparses_tags_on_fs_name_change( assert body["tags"] == [] +@patch.object(FSRomsHandler, "rename_fs_rom") +@patch.object(IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=None)) +def test_update_rom_manual_alternative_names_are_searchable( + _get_rom_by_id_mock: AsyncMock, + _rename_fs_rom_mock: AsyncMock, + client: TestClient, + access_token: str, + rom: Rom, +): + response = client.put( + f"/api/roms/{rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + data={"raw_manual_metadata": json.dumps({"alternative_names": ["ACNH"]})}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["manual_metadata"]["alternative_names"] == ["ACNH"] + + response = client.get( + "/api/roms", + headers={"Authorization": f"Bearer {access_token}"}, + params={"search_term": "acnh"}, + ) + assert response.status_code == status.HTTP_200_OK + assert [item["id"] for item in response.json()["items"]] == [rom.id] + + +@pytest.mark.parametrize( + "manual_metadata", + [{"alternative_names": "ACNH"}, {"alternative_names": [5]}, {"genres": "Racing"}], +) +@patch.object(FSRomsHandler, "rename_fs_rom") +@patch.object(IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=None)) +def test_update_rom_rejects_malformed_manual_metadata( + _get_rom_by_id_mock: AsyncMock, + _rename_fs_rom_mock: AsyncMock, + client: TestClient, + access_token: str, + rom: Rom, + manual_metadata: dict[str, Any], +): + response = client.put( + f"/api/roms/{rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + data={"raw_manual_metadata": json.dumps(manual_metadata)}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + response = client.get( + f"/api/roms/{rom.id}", headers={"Authorization": f"Bearer {access_token}"} + ) + assert response.status_code == status.HTTP_200_OK + assert not response.json()["manual_metadata"] + + @patch.object(FSRomsHandler, "rename_fs_rom") @patch.object(IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=None)) def test_update_rom_rejects_fs_name_over_255_bytes( diff --git a/backend/tests/handler/database/test_roms_alias_search.py b/backend/tests/handler/database/test_roms_alias_search.py index 6360fb354..ecb5b5b6a 100644 --- a/backend/tests/handler/database/test_roms_alias_search.py +++ b/backend/tests/handler/database/test_roms_alias_search.py @@ -76,6 +76,7 @@ def test_the_name_still_finds_the_rom(ff9: Rom, unrelated: Rom): ("igdb_metadata", "alternative_names"), ("moby_metadata", "alternate_titles"), ("ss_metadata", "alternative_names"), + ("manual_metadata", "alternative_names"), ], ) def test_each_provider_contributes_its_titles( @@ -153,6 +154,15 @@ def test_updating_the_metadata_refreshes_the_aliases(ff9: Rom): assert _search_ids("ff9") == [] +def test_hand_set_titles_replace_the_providers(ff9: Rom, unrelated: Rom): + db_rom_handler.update_rom( + ff9.id, {"manual_metadata": {"alternative_names": ["Пісня кристалів"]}} + ) + + assert _search_ids("пісня кристалів") == [ff9.id] + assert _search_ids("ff9") == [] + + def test_the_mariadb_search_matches_the_titles_column_in_one_fulltext_index(): query = db_rom_handler._filter_by_search_term(select(Rom.id), "ff9") diff --git a/backend/tests/handler/metadata/test_hasheous_handler.py b/backend/tests/handler/metadata/test_hasheous_handler.py index 09ad00ebc..f4fe88278 100644 --- a/backend/tests/handler/metadata/test_hasheous_handler.py +++ b/backend/tests/handler/metadata/test_hasheous_handler.py @@ -56,6 +56,31 @@ def test_involvements_are_optional(): assert metadata["developers"] == [] +def test_alternative_names_hold_every_title_the_proxy_returns(): + # Shaped as the proxy answers for IGDB game 427; a localization may have no name. + metadata = extract_metadata_from_igdb_rom( + { + "name": "Final Fantasy VII", + "alternative_names": { + "52451": {"id": 52451, "name": "FFVII"}, + "2950": {"id": 2950, "name": "Финальная Фантазия 7"}, + }, + "game_localizations": { + "2948": {"id": 2948, "name": "파이널 판타지 VII", "region": 2}, + "545": {"id": 545, "region": 4}, + "20": {"id": 20, "name": "FFVII", "region": 3}, + }, + } + ) + + assert metadata["alternative_names"] == [ + "Final Fantasy VII", + "FFVII", + "Финальная Фантазия 7", + "파이널 판타지 VII", + ] + + @pytest.mark.parametrize( "first_release_date", ["2005-03-14T00:00:00Z", "2005-03-14T00:00:00", "2005-03-14"], diff --git a/backend/tests/handler/metadata/test_igdb_handler.py b/backend/tests/handler/metadata/test_igdb_handler.py index 207756777..edb3fbb8c 100644 --- a/backend/tests/handler/metadata/test_igdb_handler.py +++ b/backend/tests/handler/metadata/test_igdb_handler.py @@ -24,6 +24,7 @@ from handler.metadata.igdb_handler import ( IGDBMetadata, _build_platforms_where, _platform_igdb_ids_with_twin, + build_igdb_rom, extract_metadata_from_igdb_rom, get_igdb_preferred_locale, ) @@ -973,3 +974,45 @@ class TestCompanyRoleDeduplication: "Crystal Dynamics", "Nixxes Software", ] + + +class TestAlternativeNames: + """Every title IGDB knows lands in `alternative_names`, the canonical name too.""" + + def test_localization_titles_join_the_alternative_names(self): + game = _make_game( + 1, + "Bleach: The 3rd Phantom", + alternative_names=["Burīchi Za Sādo Fantomu"], + game_localizations=[ + "ブリーチ ザ・サード・ファントム", + "Burīchi Za Sādo Fantomu", + ], + ) + + rom = build_igdb_rom(IGDBHandler(), game, None, GENESIS_IGDB_ID) + + assert rom["name"] == "Bleach: The 3rd Phantom" + assert rom["igdb_metadata"]["alternative_names"] == [ + "Bleach: The 3rd Phantom", + "Burīchi Za Sādo Fantomu", + "ブリーチ ザ・サード・ファントム", + ] + + def test_a_localized_display_name_keeps_every_title(self): + game = _make_game(1, "Bleach: The 3rd Phantom") + game["game_localizations"] = [ + GameLocalization( + id=1, + name="ブリーチ ザ・サード・ファントム", + region={"id": 1, "identifier": "ja-JP"}, + ) + ] + + rom = build_igdb_rom(IGDBHandler(), game, "ja-JP", GENESIS_IGDB_ID) + + assert rom["name"] == "ブリーチ ザ・サード・ファントム" + assert rom["igdb_metadata"]["alternative_names"] == [ + "Bleach: The 3rd Phantom", + "ブリーチ ザ・サード・ファントム", + ] diff --git a/backend/tests/models/test_rom.py b/backend/tests/models/test_rom.py index 847f1b1e8..d79e54c9c 100644 --- a/backend/tests/models/test_rom.py +++ b/backend/tests/models/test_rom.py @@ -393,6 +393,30 @@ def test_search_titles_keep_a_short_alias_after_one_past_the_cap(): assert titles == "\x1fname\x1fff7\x1f" +def test_search_titles_take_the_hand_set_titles_over_the_providers(): + titles = compute_search_titles( + "Name", + { + "igdb_metadata": {"alternative_names": ["FF7"]}, + "manual_metadata": {"alternative_names": ["FFVII"]}, + }, + ) + + assert titles == "\x1fname\x1fffvii\x1f" + + +def test_search_titles_fall_back_to_the_providers_when_the_hand_set_list_is_empty(): + titles = compute_search_titles( + "Name", + { + "igdb_metadata": {"alternative_names": ["FF7"]}, + "manual_metadata": {"alternative_names": []}, + }, + ) + + assert titles == "\x1fname\x1fff7\x1f" + + def test_search_titles_follow_orm_and_bulk_writes(platform: Platform): rom = make_rom( platform, "Final Fantasy VII", igdb_metadata={"alternative_names": ["FF7"]} @@ -415,3 +439,61 @@ def test_search_titles_follow_orm_and_bulk_writes(platform: Platform): with sync_session.begin() as session: session.get_one(Rom, rom.id).name = "Final Fantasy VII International" assert stored() == "\x1ffinal fantasy vii international\x1fff7\x1fffvii\x1f" + + +def test_alternative_names_join_every_providers_titles(): + rom = Rom( + name="Final Fantasy VII", + igdb_metadata={"alternative_names": ["FF7"]}, + moby_metadata={"alternate_titles": ["FF7", "Final Fantasy 7"]}, + ss_metadata={"alternative_names": ["FFVII"]}, + ) + + assert rom.alternative_names == ["FF7", "Final Fantasy 7", "FFVII"] + + +def test_alternative_names_keep_one_of_each_title_however_it_is_spaced(): + rom = Rom( + name="3D Baseball", + igdb_metadata={"alternative_names": ["3D Baseball: The Majors"]}, + ss_metadata={ + "alternative_names": [ + "3D Baseball: The Majors ", + "3D BASEBALL: THE MAJORS", + " ", + ] + }, + ) + + assert rom.alternative_names == ["3D Baseball: The Majors"] + + +def test_alternative_names_take_the_hand_set_titles_over_the_providers(): + rom = Rom( + name="Final Fantasy VII", + igdb_metadata={"alternative_names": ["FF7"]}, + manual_metadata={"alternative_names": ["Final Fantasy Seven"]}, + ) + + assert rom.alternative_names == ["Final Fantasy Seven"] + + +def test_alternative_names_leave_out_the_displayed_name(): + rom = Rom( + name="ファイナルファンタジーVII", + igdb_metadata={ + "alternative_names": ["Final Fantasy VII", "ファイナルファンタジーVII"] + }, + ) + + assert rom.alternative_names == ["Final Fantasy VII"] + + +def test_alternative_names_skip_a_hand_edited_blob_that_is_not_a_list(): + rom = Rom( + name="Final Fantasy VII", + igdb_metadata={"alternative_names": "FF7"}, + manual_metadata={"alternative_names": ["Final Fantasy Seven", 7]}, + ) + + assert rom.alternative_names == ["Final Fantasy Seven"] diff --git a/frontend/src/__generated__/models/ManualMetadata.ts b/frontend/src/__generated__/models/ManualMetadata.ts index d0f72c1a4..3c4a5f7cd 100644 --- a/frontend/src/__generated__/models/ManualMetadata.ts +++ b/frontend/src/__generated__/models/ManualMetadata.ts @@ -12,5 +12,6 @@ export type ManualMetadata = { age_ratings?: (Array | null); first_release_date?: (number | null); youtube_video_id?: (string | null); + alternative_names?: (Array | null); }; diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index 3803eb79a..ab9962fdb 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Възрастови оценки", "all-files": "Всички файлове", "all-styles": "Всички стилове", + "alternative-titles": "Алтернативни заглавия", "artwork": "Графика", "artwork-empty": "Няма налична графика за тази игра.", "artwork-open": "Отваряне на {name}", diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index d5f747cdc..a62efe4b5 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Hodnocení věku", "all-files": "Všechny soubory", "all-styles": "Všechny styly", + "alternative-titles": "Alternativní názvy", "artwork": "Grafika", "artwork-empty": "Pro tuto hru není k dispozici žádná grafika.", "artwork-open": "Otevřít {name}", diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index 51d61b1f8..015bcd1e0 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Altersfreigaben", "all-files": "Alle Dateien", "all-styles": "Alle Stile", + "alternative-titles": "Alternative Titel", "artwork": "Artwork", "artwork-empty": "Kein Artwork für dieses Spiel verfügbar.", "artwork-open": "{name} öffnen", diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index 57fabfc42..474a2d351 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Age ratings", "all-files": "All files", "all-styles": "All Styles", + "alternative-titles": "Alternative titles", "artwork": "Artwork", "artwork-empty": "No artwork available for this game.", "artwork-open": "Open {name}", diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index 2695baebc..67d3365c1 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Age ratings", "all-files": "All files", "all-styles": "All Styles", + "alternative-titles": "Alternative titles", "artwork": "Artwork", "artwork-empty": "No artwork available for this game.", "artwork-open": "Open {name}", diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index 8cde48ff3..b2b695454 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Clasificaciones", "all-files": "Todos los archivos", "all-styles": "Todos los Estilos", + "alternative-titles": "Títulos alternativos", "artwork": "Material gráfico", "artwork-empty": "No hay material gráfico disponible para este juego.", "artwork-open": "Abrir {name}", diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index 662951192..3601c9d0c 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Classifications par âge", "all-files": "Tous les fichiers", "all-styles": "Tous les Styles", + "alternative-titles": "Titres alternatifs", "artwork": "Illustrations", "artwork-empty": "Aucune illustration disponible pour ce jeu.", "artwork-open": "Ouvrir {name}", diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index 2ab1e58ee..8c15a539d 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Korhatár-besorolások", "all-files": "Összes fájl", "all-styles": "Minden stílus", + "alternative-titles": "Alternatív címek", "artwork": "Grafika", "artwork-empty": "Nincs elérhető grafika ehhez a játékhoz.", "artwork-open": "{name} megnyitása", diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index 273bcbc1d..5365fd565 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Classificazioni per età", "all-files": "Tutti i file", "all-styles": "Tutti gli Stili", + "alternative-titles": "Titoli alternativi", "artwork": "Artwork", "artwork-empty": "Nessun artwork disponibile per questo gioco.", "artwork-open": "Apri {name}", diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index bbdc40db2..fd6c1b8d7 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -29,6 +29,7 @@ "age-ratings": "年齢レーティング", "all-files": "すべてのファイル", "all-styles": "全スタイル", + "alternative-titles": "別タイトル", "artwork": "アートワーク", "artwork-empty": "このゲームに利用できるアートワークはありません。", "artwork-open": "{name} を開く", diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index c83873be4..db2151d0f 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -29,6 +29,7 @@ "age-ratings": "연령 등급", "all-files": "모든 파일", "all-styles": "모든 스타일", + "alternative-titles": "다른 제목", "artwork": "아트워크", "artwork-empty": "이 게임에 사용할 수 있는 아트워크가 없습니다.", "artwork-open": "{name} 열기", diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index a4592822d..88bebeafa 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Kategorie wiekowe", "all-files": "Wszystkie pliki", "all-styles": "Wszystkie Style", + "alternative-titles": "Alternatywne tytuły", "artwork": "Grafika", "artwork-empty": "Brak dostępnej grafiki dla tej gry.", "artwork-open": "Otwórz {name}", diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 8f2fe3d21..04599809d 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Classificações etárias", "all-files": "Todos os arquivos", "all-styles": "Todos os Estilos", + "alternative-titles": "Títulos alternativos", "artwork": "Arte", "artwork-empty": "Nenhuma arte disponível para este jogo.", "artwork-open": "Abrir {name}", diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index 129148c85..d1037cf68 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Clasificări pe vârstă", "all-files": "Toate fișierele", "all-styles": "Toate Stilurile", + "alternative-titles": "Titluri alternative", "artwork": "Grafică", "artwork-empty": "Nu există grafică disponibilă pentru acest joc.", "artwork-open": "Deschide {name}", diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index 0fc1e4d65..363d4af7f 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Возрастные рейтинги", "all-files": "Все файлы", "all-styles": "Все Стили", + "alternative-titles": "Альтернативные названия", "artwork": "Графика", "artwork-empty": "Для этой игры нет доступной графики.", "artwork-open": "Открыть {name}", diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json index b211a321f..2e7ccc75d 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -29,6 +29,7 @@ "age-ratings": "Yaş sınırları", "all-files": "Tüm dosyalar", "all-styles": "Tüm stiller", + "alternative-titles": "Alternatif başlıklar", "artwork": "Görsel", "artwork-empty": "Bu oyun için kullanılabilir görsel yok.", "artwork-open": "{name} öğesini aç", diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index 2420bb6e8..761484277 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -29,6 +29,7 @@ "age-ratings": "年龄分级", "all-files": "所有文件", "all-styles": "所有风格", + "alternative-titles": "其他标题", "artwork": "美术图", "artwork-empty": "此游戏没有可用的美术图。", "artwork-open": "打开 {name}", diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index d50a819a5..78af2ce53 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -29,6 +29,7 @@ "age-ratings": "年齡分級", "all-files": "所有檔案", "all-styles": "所有風格", + "alternative-titles": "其他標題", "artwork": "美術圖", "artwork-empty": "此遊戲沒有可用的美術圖。", "artwork-open": "開啟 {name}", diff --git a/frontend/src/v2/components/EditRom/AdditionalDetails.test.ts b/frontend/src/v2/components/EditRom/AdditionalDetails.test.ts new file mode 100644 index 000000000..d83a71b3d --- /dev/null +++ b/frontend/src/v2/components/EditRom/AdditionalDetails.test.ts @@ -0,0 +1,63 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import type { UpdateRom } from "@/services/api/rom"; +import { makeRom } from "@/utils/rom.fixtures"; +import AdditionalDetails from "./AdditionalDetails.vue"; + +vi.mock("vue-i18n"); + +const RComboboxField = { + props: { + label: { type: String, default: "" }, + allowCommas: { type: Boolean, default: false }, + }, + emits: ["update:modelValue"], + template: `
`, +}; + +function titlesField(wrapper: ReturnType) { + const field = wrapper + .findAllComponents(RComboboxField) + .find((c) => c.props("label") === "rom.alternative-titles"); + if (!field) throw new Error("Alternative titles field not rendered"); + return field; +} + +function mountDetails(rom: UpdateRom) { + return mount(AdditionalDetails, { + props: { rom }, + global: { + stubs: { RComboboxField, RDateField: true, RTextField: true }, + }, + }); +} + +describe("AdditionalDetails alternative titles", () => { + it("writes the titles into the manual metadata", async () => { + const rom = makeRom({ manual_metadata: { genres: ["Racing"] } }); + const wrapper = mountDetails(rom); + + await titlesField(wrapper).vm.$emit("update:modelValue", ["ACNH"]); + + expect(wrapper.emitted("update:rom")?.[0]?.[0]).toMatchObject({ + manual_metadata: { genres: ["Racing"], alternative_names: ["ACNH"] }, + }); + }); + + it("keeps the commas inside a title", () => { + const wrapper = mountDetails(makeRom({})); + + expect(titlesField(wrapper).props("allowCommas")).toBe(true); + }); + + it("clears the titles", async () => { + const rom = makeRom({ manual_metadata: { alternative_names: ["ACNH"] } }); + const wrapper = mountDetails(rom); + + await titlesField(wrapper).vm.$emit("update:modelValue", []); + + expect(wrapper.emitted("update:rom")?.[0]?.[0]).toMatchObject({ + manual_metadata: { alternative_names: [] }, + }); + }); +}); diff --git a/frontend/src/v2/components/EditRom/AdditionalDetails.vue b/frontend/src/v2/components/EditRom/AdditionalDetails.vue index b8135e275..e29c66e69 100644 --- a/frontend/src/v2/components/EditRom/AdditionalDetails.vue +++ b/frontend/src/v2/components/EditRom/AdditionalDetails.vue @@ -1,8 +1,6 @@