mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
feat: searchable alternative titles for ROMs (#5111)
* feat: searchable alternative titles for ROMs Add an editable "Alternative titles" field to manual metadata, folded into the search titles alongside provider aliases. IGDB localization titles now join the ROM's alternative names, and the canonical IGDB name is kept as one when a regional title is displayed. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix: list hand-added titles in alternative_names, test the field Address Greptile review on #5111: the response's alternative_names now appends the hand-added titles to the first provider's, and the Alternative titles field gets a component test. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * test: type the alternative titles field lookup Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix: code-review pass on alternative titles Validate title lists from client-written metadata in one helper, list hand-added titles first so the search text cap never drops them, and let the titles combobox keep commas inside a title. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor: simplify pass on alternative titles Dedupe IGDB titles once in build_igdb_rom, inline the manual title source, and reword the combobox comma comment. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * docs: review-polish pass on alternative titles Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * docs: humanizer pass on alternative titles Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix: hand-set alternative titles override, providers join A non-empty manual list replaces every provider's titles, in the list and in search; otherwise every provider's titles are joined. IGDB and Hasheous store every title they know, the canonical name and regional localizations included, and the ROM's displayed name is left out only when listing them. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix: validate manual metadata on ROM update A malformed raw_manual_metadata is rejected with a 422 instead of being stored and failing every later response for that ROM. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix: list each alternative title once however it is spaced Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c402da2599
commit
f8b3173bea
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+48
-19
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
"ブリーチ ザ・サード・ファントム",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -12,5 +12,6 @@ export type ManualMetadata = {
|
||||
age_ratings?: (Array<string> | null);
|
||||
first_release_date?: (number | null);
|
||||
youtube_video_id?: (string | null);
|
||||
alternative_names?: (Array<string> | null);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"age-ratings": "Възрастови оценки",
|
||||
"all-files": "Всички файлове",
|
||||
"all-styles": "Всички стилове",
|
||||
"alternative-titles": "Алтернативни заглавия",
|
||||
"artwork": "Графика",
|
||||
"artwork-empty": "Няма налична графика за тази игра.",
|
||||
"artwork-open": "Отваряне на {name}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"age-ratings": "年齢レーティング",
|
||||
"all-files": "すべてのファイル",
|
||||
"all-styles": "全スタイル",
|
||||
"alternative-titles": "別タイトル",
|
||||
"artwork": "アートワーク",
|
||||
"artwork-empty": "このゲームに利用できるアートワークはありません。",
|
||||
"artwork-open": "{name} を開く",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"age-ratings": "연령 등급",
|
||||
"all-files": "모든 파일",
|
||||
"all-styles": "모든 스타일",
|
||||
"alternative-titles": "다른 제목",
|
||||
"artwork": "아트워크",
|
||||
"artwork-empty": "이 게임에 사용할 수 있는 아트워크가 없습니다.",
|
||||
"artwork-open": "{name} 열기",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"age-ratings": "Возрастные рейтинги",
|
||||
"all-files": "Все файлы",
|
||||
"all-styles": "Все Стили",
|
||||
"alternative-titles": "Альтернативные названия",
|
||||
"artwork": "Графика",
|
||||
"artwork-empty": "Для этой игры нет доступной графики.",
|
||||
"artwork-open": "Открыть {name}",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"age-ratings": "年龄分级",
|
||||
"all-files": "所有文件",
|
||||
"all-styles": "所有风格",
|
||||
"alternative-titles": "其他标题",
|
||||
"artwork": "美术图",
|
||||
"artwork-empty": "此游戏没有可用的美术图。",
|
||||
"artwork-open": "打开 {name}",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"age-ratings": "年齡分級",
|
||||
"all-files": "所有檔案",
|
||||
"all-styles": "所有風格",
|
||||
"alternative-titles": "其他標題",
|
||||
"artwork": "美術圖",
|
||||
"artwork-empty": "此遊戲沒有可用的美術圖。",
|
||||
"artwork-open": "開啟 {name}",
|
||||
|
||||
@@ -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: `<div />`,
|
||||
};
|
||||
|
||||
function titlesField(wrapper: ReturnType<typeof mountDetails>) {
|
||||
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: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// AdditionalDetails (v2): manual metadata overrides for the edit-ROM
|
||||
// dialog. Companies / genres / franchises / game modes / age ratings
|
||||
// are multi-tag combobox fields; first release date is a date picker;
|
||||
// YouTube video ID is a plain text field.
|
||||
// dialog.
|
||||
//
|
||||
// All writes go through `manual_metadata`: the backend layer where
|
||||
// admins override automatic scraper output. Emitting `update:rom` with
|
||||
@@ -76,6 +74,16 @@ function updateManualMetadata(
|
||||
|
||||
<template>
|
||||
<div class="r-v2-additional">
|
||||
<RComboboxField
|
||||
:model-value="manual.alternative_names ?? []"
|
||||
:label="t('rom.alternative-titles')"
|
||||
prefix-label="stacked"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
allow-commas
|
||||
clearable
|
||||
@update:model-value="(v) => updateManualMetadata('alternative_names', v)"
|
||||
/>
|
||||
<RComboboxField
|
||||
:model-value="manual.companies ?? []"
|
||||
:label="t('rom.companies')"
|
||||
|
||||
@@ -25,6 +25,7 @@ const meta: Meta<typeof RComboboxField> = {
|
||||
disabled: { control: "boolean" },
|
||||
closableChips: { control: "boolean" },
|
||||
noSuggestions: { control: "boolean" },
|
||||
allowCommas: { control: "boolean" },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -136,3 +136,28 @@ describe("RComboboxField inside an overlay", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RComboboxField commas", () => {
|
||||
async function type(allowCommas: boolean, text: string) {
|
||||
const wrapper = mount(RComboboxField, {
|
||||
props: { modelValue: [], allowCommas },
|
||||
});
|
||||
const input = wrapper.get("input");
|
||||
await input.setValue(text);
|
||||
await input.trigger("keydown", { key: "Enter" });
|
||||
return wrapper.emitted("update:modelValue")?.[0]?.[0];
|
||||
}
|
||||
|
||||
it("splits a typed value on commas by default", async () => {
|
||||
expect(await type(false, "Hey You, Pikachu!")).toEqual([
|
||||
"Hey You",
|
||||
"Pikachu!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the commas of a value when allowed", async () => {
|
||||
expect(await type(true, "Hey You, Pikachu!")).toEqual([
|
||||
"Hey You, Pikachu!",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,9 @@ interface Props {
|
||||
closableChips?: boolean;
|
||||
/** Drop the autocomplete dropdown entirely. Pure free-text input. */
|
||||
noSuggestions?: boolean;
|
||||
/** Keep commas inside a value instead of committing and splitting on
|
||||
* them, for values like titles that can hold one. */
|
||||
allowCommas?: boolean;
|
||||
/** Render a field-level X that wipes every committed chip in one
|
||||
* click. Sits next to the input on the right edge of the field, so
|
||||
* the affordance reads identically to RTextField's clearable. */
|
||||
@@ -83,6 +86,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
closableChips: true,
|
||||
noSuggestions: false,
|
||||
allowCommas: false,
|
||||
clearable: false,
|
||||
});
|
||||
|
||||
@@ -154,12 +158,14 @@ const { floatingStyles } = useFloating(fieldRef, panelRef, {
|
||||
function commit(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
// Allow comma-separated paste (`tag1, tag2, tag3`), split, dedupe
|
||||
// against the current set, and commit them in one update.
|
||||
const parts = trimmed
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
// Unless `allowCommas`, split a comma-separated paste (`tag1, tag2`),
|
||||
// dedupe against the current set, and commit them in one update.
|
||||
const parts = props.allowCommas
|
||||
? [trimmed]
|
||||
: trimmed
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
const taken = new Set(chips.value.map((c) => c.toLowerCase()));
|
||||
const next = [...chips.value];
|
||||
@@ -238,6 +244,7 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
case ",": {
|
||||
// Comma also commits: matches the paste-friendly contract.
|
||||
if (props.allowCommas) break;
|
||||
e.preventDefault();
|
||||
commit(query.value);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user