From a798049788989c7f02c9b7b001d38a5cad1e98c2 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 12 Sep 2024 21:09:56 -0400 Subject: [PATCH 01/13] feat(game): add sort_title field and backfill command --- app/Filament/Resources/GameResource.php | 10 +++ app/Models/Game.php | 2 + .../WriteGameSortTitleFromGameTitleAction.php | 62 ++++++++++++++++ app/Platform/AppServiceProvider.php | 2 + app/Platform/Commands/WriteGameSortTitles.php | 58 +++++++++++++++ app/Policies/GamePolicy.php | 8 +-- database/migrations/community/.gitkeep | 0 ...024_09_12_000000_update_gamedata_table.php | 31 ++++++++ ...teGameSortTitleFromGameTitleActionTest.php | 71 +++++++++++++++++++ 9 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php create mode 100644 app/Platform/Commands/WriteGameSortTitles.php delete mode 100644 database/migrations/community/.gitkeep create mode 100644 database/migrations/platform/2024_09_12_000000_update_gamedata_table.php create mode 100644 tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php diff --git a/app/Filament/Resources/GameResource.php b/app/Filament/Resources/GameResource.php index 547b494657..b687fe8dfc 100644 --- a/app/Filament/Resources/GameResource.php +++ b/app/Filament/Resources/GameResource.php @@ -89,6 +89,9 @@ public static function infolist(Infolist $infolist): Infolist Infolists\Components\TextEntry::make('title'), + Infolists\Components\TextEntry::make('sort_title') + ->label('Sort Title'), + Infolists\Components\TextEntry::make('forumTopic.id') ->label('Forum Topic ID') ->url(fn (?int $state) => url("viewtopic.php?t={$state}")) @@ -240,6 +243,13 @@ public static function form(Form $form): Form ->maxLength(80) ->disabled(!$user->can('updateField', [$form->model, 'Title'])), + Forms\Components\TextInput::make('sort_title') + ->required() + ->label('Sort Title') + ->minLength(2) + ->disabled(!$user->can('updateField', [$form->model, 'sort_title'])) + ->helperText('Manually override the automatic sorting behavior of the game\'s title. For example, "Final Fantasy IV" might be "final fantasy 04". DON\'T CHANGE THIS UNLESS YOU KNOW WHAT YOU\'RE DOING.'), + Forms\Components\TextInput::make('ForumTopicID') ->label('Forum Topic ID') ->numeric() diff --git a/app/Models/Game.php b/app/Models/Game.php index 55fd2bbabc..3d19be652d 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -71,6 +71,7 @@ class Game extends BaseModel implements HasComments, HasMedia protected $fillable = [ 'release', 'Title', + 'sort_title', 'ForumTopicID', 'Publisher', 'Developer', @@ -87,6 +88,7 @@ class Game extends BaseModel implements HasComments, HasMedia protected $visible = [ 'ID', 'Title', + 'sort_title', 'ConsoleID', 'ForumTopicID', 'Flags', diff --git a/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php b/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php new file mode 100644 index 0000000000..82c818655f --- /dev/null +++ b/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php @@ -0,0 +1,62 @@ +sort_title && $shouldRespectCustomSortTitle) { + $originalSortTitle = $this->computeSortTitle($originalTitle); + + if ($game->sort_title !== $originalSortTitle) { + return; + } + } + + // Otherwise, compute and save the new sort title. + $game->sort_title = $this->computeSortTitle($game->title); + $game->save(); + } + + /** + * Sort titles ensure games in lists are sorted properly. + * For titles starting with "~", the sort order is determined by the content + * within the "~" markers followed by the content after the "~". This ensures + * that titles with "~" are grouped together and sorted alphabetically based + * on their designated categories and then by their actual game title. + * + * The "~" prefix is retained in the SortTitle of games with "~" to ensure these + * games are sorted at the end of the list, maintaining a clear separation from + * non-prefixed titles. This approach allows game titles to be grouped and sorted + * in a specific order: + * + * 1. Non-prefixed titles are sorted alphabetically at the beginning of the list. + * 2. Titles prefixed with "~" are grouped at the end, sorted first by the category + * specified within the "~" markers, and then alphabetically by the title following + * the "~". + */ + private function computeSortTitle(string $title): string + { + $sortTitle = mb_strtolower($title); + + if ($sortTitle[0] === '~') { + $endOfFirstTilde = strpos($sortTitle, '~', 1); + if ($endOfFirstTilde !== false) { + $withinTildes = substr($sortTitle, 1, $endOfFirstTilde - 1); + $afterTildes = trim(substr($sortTitle, $endOfFirstTilde + 1)); + + $sortTitle = '~' . $withinTildes . ' ' . $afterTildes; + } + } + + return $sortTitle; + } +} diff --git a/app/Platform/AppServiceProvider.php b/app/Platform/AppServiceProvider.php index b2d81897dd..101a913fd0 100644 --- a/app/Platform/AppServiceProvider.php +++ b/app/Platform/AppServiceProvider.php @@ -53,6 +53,7 @@ use App\Platform\Commands\UpdatePlayerMetrics; use App\Platform\Commands\UpdatePlayerPointsStats; use App\Platform\Commands\UpdateTotalGamesCount; +use App\Platform\Commands\WriteGameSortTitles; use App\Platform\Components\GameCard; use App\Platform\Components\GameTitle; use Illuminate\Console\Scheduling\Schedule; @@ -73,6 +74,7 @@ public function boot(): void UpdateGameMetrics::class, UpdateGameAchievementsMetrics::class, UpdateGamePlayerGames::class, + WriteGameSortTitles::class, // Game Hashes NoIntroImport::class, diff --git a/app/Platform/Commands/WriteGameSortTitles.php b/app/Platform/Commands/WriteGameSortTitles.php new file mode 100644 index 0000000000..c3587f0719 --- /dev/null +++ b/app/Platform/Commands/WriteGameSortTitles.php @@ -0,0 +1,58 @@ +argument('gameId'); + if ($gameId !== null) { + $game = Game::findOrFail($gameId); + + $this->info("\nUpserting a sort title for [{$game->id}:{$game->title}]."); + + (new WriteGameSortTitleFromGameTitleAction())->execute( + $game, + $game->title, + shouldRespectCustomSortTitle: false, + ); + + $this->info('Done.'); + } else { + $gamesCount = Game::count(); + $this->info("\nUpserting sort titles for {$gamesCount} games."); + + $progressBar = $this->output->createProgressBar($gamesCount); + $progressBar->start(); + + $action = new WriteGameSortTitleFromGameTitleAction(); + Game::query()->chunk(100, function ($games) use ($progressBar, $action) { + foreach ($games as $game) { + $action->execute($game, $game->title, shouldRespectCustomSortTitle: false); + } + + $progressBar->advance(count($games)); + }); + + $progressBar->finish(); + + $this->info("\nAll sort titles have been upserted."); + } + } +} diff --git a/app/Policies/GamePolicy.php b/app/Policies/GamePolicy.php index 9ce45dfbf3..38d378eb56 100644 --- a/app/Policies/GamePolicy.php +++ b/app/Policies/GamePolicy.php @@ -96,7 +96,7 @@ public function updateField(User $user, Game $game, string $fieldName): bool Role::DEVELOPER_JUNIOR => ['GuideURL', 'Developer', 'Publisher', 'Genre', 'released_at', 'released_at_granularity'], Role::DEVELOPER => ['Title', 'GuideURL', 'Developer', 'Publisher', 'Genre', 'released_at', 'released_at_granularity'], - Role::DEVELOPER_STAFF => ['Title', 'GuideURL', 'Developer', 'Publisher', 'Genre', 'released_at', 'released_at_granularity'], + Role::DEVELOPER_STAFF => ['Title', 'sort_title', 'GuideURL', 'Developer', 'Publisher', 'Genre', 'released_at', 'released_at_granularity'], ]; $userRoles = $user->getRoleNames(); @@ -131,8 +131,7 @@ public function createForumTopic(User $user, Game $game): bool Role::DEVELOPER, Role::FORUM_MANAGER, Role::MODERATOR, - ]) - || $user->getAttribute('Permissions') >= Permissions::Developer; + ]); } // TODO rename to viewActivitylog or use manage() ? @@ -143,8 +142,7 @@ public function viewModifications(User $user): bool Role::DEVELOPER_STAFF, Role::DEVELOPER, Role::DEVELOPER_JUNIOR, - ]) - || $user->getAttribute('Permissions') >= Permissions::JuniorDeveloper; + ]); } private function canDeveloperJuniorUpdateGame(User $user, Game $game): bool diff --git a/database/migrations/community/.gitkeep b/database/migrations/community/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/database/migrations/platform/2024_09_12_000000_update_gamedata_table.php b/database/migrations/platform/2024_09_12_000000_update_gamedata_table.php new file mode 100644 index 0000000000..748cf79bf4 --- /dev/null +++ b/database/migrations/platform/2024_09_12_000000_update_gamedata_table.php @@ -0,0 +1,31 @@ +string('sort_title')->after('Title')->nullable(); + }); + + Schema::table('GameData', function (Blueprint $table) { + $table->index('sort_title'); + }); + } + + public function down(): void + { + Schema::table('GameData', function (Blueprint $table) { + $table->dropIndex('sort_title'); + }); + + Schema::table('GameData', function (Blueprint $table) { + $table->dropColumn('sort_title'); + }); + } +}; diff --git a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php new file mode 100644 index 0000000000..a106498b4a --- /dev/null +++ b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php @@ -0,0 +1,71 @@ +create(['Title' => 'Sonic the Hedgehog']); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); + $game = $game->fresh(); + + // Assert + $this->assertEquals('sonic the hedgehog', $game->sort_title); + } + + public function testItPreservesCustomSortTitlesByDefault(): void + { + // Arrange + $game = Game::factory()->create(['Title' => 'Final Fantasy IV', 'sort_title' => 'final fantasy 04']); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); + $game = $game->fresh(); + + // Assert + $this->assertEquals('final fantasy 04', $game->sort_title); + } + + public function testItCanBeConfiguredToOverrideCustomSortTitles(): void + { + // Arrange + $game = Game::factory()->create(['Title' => 'Final Fantasy IV', 'sort_title' => 'final fantasy 04']); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute( + $game, + $game->title, + shouldRespectCustomSortTitle: false, + ); + $game = $game->fresh(); + + // Assert + $this->assertEquals('final fantasy iv', $game->sort_title); + } + + public function testItCorrectlyHandlesGameTitlesWithTildes(): void + { + // Arrange + $game = Game::factory()->create(['Title' => '~Homebrew~ Classic Kong']); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); + $game = $game->fresh(); + + // Assert + $this->assertEquals('~homebrew classic kong', $game->sort_title); + } +} From 518cfbcf7a8d64ce50e8efb769028dec9f4f2670 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 12 Sep 2024 21:39:00 -0400 Subject: [PATCH 02/13] feat: automatically write to sort_title on game title change --- app/Filament/Resources/GameResource.php | 32 +++++++++++++++++-- app/Helpers/database/game.php | 8 ++++- .../WriteGameSortTitleFromGameTitleAction.php | 6 ++-- app/Platform/Services/GameListService.php | 1 + 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/app/Filament/Resources/GameResource.php b/app/Filament/Resources/GameResource.php index b687fe8dfc..a3ddab392f 100644 --- a/app/Filament/Resources/GameResource.php +++ b/app/Filament/Resources/GameResource.php @@ -15,6 +15,7 @@ use App\Models\Game; use App\Models\System; use App\Models\User; +use App\Platform\Actions\WriteGameSortTitleFromGameTitleAction; use Filament\Forms; use Filament\Forms\Form; use Filament\Forms\Get; @@ -241,14 +242,41 @@ public static function form(Form $form): Form ->required() ->minLength(2) ->maxLength(80) - ->disabled(!$user->can('updateField', [$form->model, 'Title'])), + ->disabled(!$user->can('updateField', [$form->model, 'Title'])) + ->afterStateUpdated(function (Game $record, callable $set, callable $get, string $state) { + // If the user is updating the sort title, don't try to override their update of that field. + if ($get('sort_title') !== $get('original_sort_title')) { + return; + } + + $newTitle = $state; + $originalTitle = $record->title; + + $record->title = $newTitle; + $record->save(); + $record->refresh(); + + $newSortTitle = (new WriteGameSortTitleFromGameTitleAction())->execute( + $record, + $originalTitle, + ); + + if ($newSortTitle) { + $set('sort_title', $newSortTitle); + $set('original_sort_title', $newSortTitle); + } + }), Forms\Components\TextInput::make('sort_title') ->required() ->label('Sort Title') ->minLength(2) ->disabled(!$user->can('updateField', [$form->model, 'sort_title'])) - ->helperText('Manually override the automatic sorting behavior of the game\'s title. For example, "Final Fantasy IV" might be "final fantasy 04". DON\'T CHANGE THIS UNLESS YOU KNOW WHAT YOU\'RE DOING.'), + ->helperText('Manually override the automatic sorting behavior of the game\'s title. For example, "Final Fantasy IV" might be "final fantasy 04". DON\'T CHANGE THIS UNLESS YOU KNOW WHAT YOU\'RE DOING.') + ->reactive() + ->afterStateHydrated(function (callable $set, callable $get, string $state) { + $set('original_sort_title', $state); + }), Forms\Components\TextInput::make('ForumTopicID') ->label('Forum Topic ID') diff --git a/app/Helpers/database/game.php b/app/Helpers/database/game.php index 63346bfee4..230608b358 100644 --- a/app/Helpers/database/game.php +++ b/app/Helpers/database/game.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Platform\Actions\TrimGameMetadata; use App\Platform\Actions\UpdateGameSetFromGameAlternativesModification; +use App\Platform\Actions\WriteGameSortTitleFromGameTitleAction; use App\Platform\Enums\AchievementFlag; use Illuminate\Support\Facades\Log; use Spatie\Activitylog\Facades\CauserResolver; @@ -648,9 +649,14 @@ function modifyGameTitle(string $username, int $gameId, string $value): bool return false; } - $game->Title = $value; + $originalTitle = $game->title; + + $game->title = $value; $game->save(); + // Also, if necessary, update the game's sort title. + (new WriteGameSortTitleFromGameTitleAction())->execute($game, $originalTitle); + addArticleComment('Server', ArticleType::GameModification, $gameId, "{$username} changed the game name"); return true; diff --git a/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php b/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php index 82c818655f..e0454f17c6 100644 --- a/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php +++ b/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php @@ -8,7 +8,7 @@ class WriteGameSortTitleFromGameTitleAction { - public function execute(Game $game, string $originalTitle, bool $shouldRespectCustomSortTitle = true): void + public function execute(Game $game, string $originalTitle, bool $shouldRespectCustomSortTitle = true): ?string { // Compute the original sort title based on the original game title. // We do this to determine if a developer has given the game a custom sort title, because @@ -17,13 +17,15 @@ public function execute(Game $game, string $originalTitle, bool $shouldRespectCu $originalSortTitle = $this->computeSortTitle($originalTitle); if ($game->sort_title !== $originalSortTitle) { - return; + return null; } } // Otherwise, compute and save the new sort title. $game->sort_title = $this->computeSortTitle($game->title); $game->save(); + + return $game->sort_title; } /** diff --git a/app/Platform/Services/GameListService.php b/app/Platform/Services/GameListService.php index 3625d9e3d6..5320d487dc 100644 --- a/app/Platform/Services/GameListService.php +++ b/app/Platform/Services/GameListService.php @@ -126,6 +126,7 @@ public function initializeGameList(array $gameIds, bool $allowNonGameSystems = f * specified within the "~" markers, and then alphabetically by the title following * the "~". */ + // TODO use the sort_title attribute on the games table $game['SortTitle'] = mb_strtolower($game['Title']); if ($game['SortTitle'][0] === '~') { $endOfFirstTilde = strpos($game['SortTitle'], '~', 1); From d41949f29c31c22d208b50cf4e80dd28d23c8946 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 13 Sep 2024 21:21:16 -0400 Subject: [PATCH 03/13] fix: start working through feedback --- app/Helpers/database/game.php | 2 + app/Models/Game.php | 1 + .../Actions/ComputeGameSortTitleAction.php | 42 +++++++++++++++++++ .../WriteGameSortTitleFromGameTitleAction.php | 40 ++---------------- database/factories/GameFactory.php | 7 +++- ...024_09_12_000000_update_gamedata_table.php | 2 +- 6 files changed, 56 insertions(+), 38 deletions(-) create mode 100644 app/Platform/Actions/ComputeGameSortTitleAction.php diff --git a/app/Helpers/database/game.php b/app/Helpers/database/game.php index 230608b358..f389762e63 100644 --- a/app/Helpers/database/game.php +++ b/app/Helpers/database/game.php @@ -6,6 +6,7 @@ use App\Models\Game; use App\Models\PlayerGame; use App\Models\User; +use App\Platform\Actions\ComputeGameSortTitleAction; use App\Platform\Actions\TrimGameMetadata; use App\Platform\Actions\UpdateGameSetFromGameAlternativesModification; use App\Platform\Actions\WriteGameSortTitleFromGameTitleAction; @@ -798,6 +799,7 @@ function createNewGame(string $title, int $systemId): ?array try { $game = new Game(); $game->Title = $title; + $game->sort_title = (new ComputeGameSortTitleAction())->execute($title); $game->ConsoleID = $systemId; $game->ForumTopicID = null; $game->Flags = 0; diff --git a/app/Models/Game.php b/app/Models/Game.php index 3d19be652d..713a83eb4d 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -126,6 +126,7 @@ public function getActivitylogOptions(): LogOptions return LogOptions::defaults() ->logOnly([ 'Title', + 'sort_title', 'ForumTopicID', 'GuideURL', 'Publisher', diff --git a/app/Platform/Actions/ComputeGameSortTitleAction.php b/app/Platform/Actions/ComputeGameSortTitleAction.php new file mode 100644 index 0000000000..0c7ca06eb6 --- /dev/null +++ b/app/Platform/Actions/ComputeGameSortTitleAction.php @@ -0,0 +1,42 @@ +sort_title && $shouldRespectCustomSortTitle) { - $originalSortTitle = $this->computeSortTitle($originalTitle); + $originalSortTitle = $computeAction->execute($originalTitle); if ($game->sort_title !== $originalSortTitle) { return null; @@ -22,43 +24,9 @@ public function execute(Game $game, string $originalTitle, bool $shouldRespectCu } // Otherwise, compute and save the new sort title. - $game->sort_title = $this->computeSortTitle($game->title); + $game->sort_title = $computeAction->execute($game->title); $game->save(); return $game->sort_title; } - - /** - * Sort titles ensure games in lists are sorted properly. - * For titles starting with "~", the sort order is determined by the content - * within the "~" markers followed by the content after the "~". This ensures - * that titles with "~" are grouped together and sorted alphabetically based - * on their designated categories and then by their actual game title. - * - * The "~" prefix is retained in the SortTitle of games with "~" to ensure these - * games are sorted at the end of the list, maintaining a clear separation from - * non-prefixed titles. This approach allows game titles to be grouped and sorted - * in a specific order: - * - * 1. Non-prefixed titles are sorted alphabetically at the beginning of the list. - * 2. Titles prefixed with "~" are grouped at the end, sorted first by the category - * specified within the "~" markers, and then alphabetically by the title following - * the "~". - */ - private function computeSortTitle(string $title): string - { - $sortTitle = mb_strtolower($title); - - if ($sortTitle[0] === '~') { - $endOfFirstTilde = strpos($sortTitle, '~', 1); - if ($endOfFirstTilde !== false) { - $withinTildes = substr($sortTitle, 1, $endOfFirstTilde - 1); - $afterTildes = trim(substr($sortTitle, $endOfFirstTilde + 1)); - - $sortTitle = '~' . $withinTildes . ' ' . $afterTildes; - } - } - - return $sortTitle; - } } diff --git a/database/factories/GameFactory.php b/database/factories/GameFactory.php index d1c3733dcf..06bd711cbd 100644 --- a/database/factories/GameFactory.php +++ b/database/factories/GameFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Models\Game; +use App\Platform\Actions\ComputeGameSortTitleAction; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -19,8 +20,12 @@ class GameFactory extends Factory */ public function definition(): array { + $title = ucwords(fake()->words(2, true)); + $sortTitle = (new ComputeGameSortTitleAction())->execute($title); + return [ - 'Title' => ucwords(fake()->words(2, true)), + 'Title' => $title, + 'sort_title' => $sortTitle, 'ConsoleID' => 0, 'ImageIcon' => '/Images/000001.png', ]; diff --git a/database/migrations/platform/2024_09_12_000000_update_gamedata_table.php b/database/migrations/platform/2024_09_12_000000_update_gamedata_table.php index 748cf79bf4..58ba561a59 100644 --- a/database/migrations/platform/2024_09_12_000000_update_gamedata_table.php +++ b/database/migrations/platform/2024_09_12_000000_update_gamedata_table.php @@ -21,7 +21,7 @@ public function up(): void public function down(): void { Schema::table('GameData', function (Blueprint $table) { - $table->dropIndex('sort_title'); + $table->dropIndex(['sort_title']); }); Schema::table('GameData', function (Blueprint $table) { From b0c7af3c5c1153acb72dc9eeaaa5bc3313fa08d0 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 13 Sep 2024 21:22:41 -0400 Subject: [PATCH 04/13] chore: fix tests --- .../Action/WriteGameSortTitleFromGameTitleActionTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php index a106498b4a..8763de35f2 100644 --- a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php +++ b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php @@ -16,7 +16,7 @@ class WriteGameSortTitleFromGameTitleActionTest extends TestCase public function testItGeneratesSortTitleBasedOnGameTitle(): void { // Arrange - $game = Game::factory()->create(['Title' => 'Sonic the Hedgehog']); + $game = Game::factory()->create(['Title' => 'Sonic the Hedgehog', 'sort_title' => null]); // Act (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); @@ -59,7 +59,7 @@ public function testItCanBeConfiguredToOverrideCustomSortTitles(): void public function testItCorrectlyHandlesGameTitlesWithTildes(): void { // Arrange - $game = Game::factory()->create(['Title' => '~Homebrew~ Classic Kong']); + $game = Game::factory()->create(['Title' => '~Homebrew~ Classic Kong', 'sort_title' => null]); // Act (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); From 5cf38871d970ae8c0a7134fbaab436d4d421e47c Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 13 Sep 2024 21:28:33 -0400 Subject: [PATCH 05/13] fix: adjust description label --- app/Filament/Resources/GameResource.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Filament/Resources/GameResource.php b/app/Filament/Resources/GameResource.php index a3ddab392f..c05495e9c3 100644 --- a/app/Filament/Resources/GameResource.php +++ b/app/Filament/Resources/GameResource.php @@ -272,7 +272,7 @@ public static function form(Form $form): Form ->label('Sort Title') ->minLength(2) ->disabled(!$user->can('updateField', [$form->model, 'sort_title'])) - ->helperText('Manually override the automatic sorting behavior of the game\'s title. For example, "Final Fantasy IV" might be "final fantasy 04". DON\'T CHANGE THIS UNLESS YOU KNOW WHAT YOU\'RE DOING.') + ->helperText('Normalized title for sorting purposes. For example, "The Goonies II" would sort as "goonies 02". DON\'T CHANGE THIS UNLESS YOU KNOW WHAT YOU\'RE DOING.') ->reactive() ->afterStateHydrated(function (callable $set, callable $get, string $state) { $set('original_sort_title', $state); From 24c831885fb11062e661bd9836d86b7e261e0059 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 13 Sep 2024 21:30:06 -0400 Subject: [PATCH 06/13] fix(GameResource): don't crash on null values --- app/Filament/Resources/GameResource.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Filament/Resources/GameResource.php b/app/Filament/Resources/GameResource.php index c05495e9c3..610ee099fa 100644 --- a/app/Filament/Resources/GameResource.php +++ b/app/Filament/Resources/GameResource.php @@ -274,8 +274,8 @@ public static function form(Form $form): Form ->disabled(!$user->can('updateField', [$form->model, 'sort_title'])) ->helperText('Normalized title for sorting purposes. For example, "The Goonies II" would sort as "goonies 02". DON\'T CHANGE THIS UNLESS YOU KNOW WHAT YOU\'RE DOING.') ->reactive() - ->afterStateHydrated(function (callable $set, callable $get, string $state) { - $set('original_sort_title', $state); + ->afterStateHydrated(function (callable $set, callable $get, ?string $state) { + $set('original_sort_title', $state ?? ''); }), Forms\Components\TextInput::make('ForumTopicID') From def3cbc1560399bb7b3755aafad2adf752e86e67 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 13 Sep 2024 22:45:44 -0400 Subject: [PATCH 07/13] fix: address pr feedback --- .../Actions/ComputeGameSortTitleAction.php | 83 +++++++++++++----- ...teGameSortTitleFromGameTitleActionTest.php | 84 ++++++++++++++++++- 2 files changed, 143 insertions(+), 24 deletions(-) diff --git a/app/Platform/Actions/ComputeGameSortTitleAction.php b/app/Platform/Actions/ComputeGameSortTitleAction.php index 0c7ca06eb6..ff7f24939e 100644 --- a/app/Platform/Actions/ComputeGameSortTitleAction.php +++ b/app/Platform/Actions/ComputeGameSortTitleAction.php @@ -6,37 +6,80 @@ class ComputeGameSortTitleAction { + public function execute(string $gameTitle): string + { + $sortTitle = $this->replaceRomanNumerals($gameTitle); + $sortTitle = $this->removeArticles($sortTitle); + $sortTitle = mb_strtolower($sortTitle); + $sortTitle = $this->fixTagTildes($sortTitle); + + return $sortTitle; + } + + /** + * Replace Roman numerals with their corresponding padded numeric + * equivalents (eg: "IV" -> "04"). + */ + private function replaceRomanNumerals(string $title): string + { + $romanNumerals = [ + 'I' => 1, 'II' => 2, 'III' => 3, 'IV' => 4, 'V' => 5, + 'VI' => 6, 'VII' => 7, 'VIII' => 8, 'IX' => 9, 'X' => 10, + 'XI' => 11, 'XII' => 12, 'XIII' => 13, 'XIV' => 14, 'XV' => 15, + 'XVI' => 16, 'XVII' => 17, 'XVIII' => 18, 'XIX' => 19, 'XX' => 20, + ]; + + $title = preg_replace_callback( + '/\b([IVX]+)\b/', + function ($matches) use ($romanNumerals) { + $roman = $matches[1]; + if (isset($romanNumerals[$roman])) { + return sprintf('%02d', $romanNumerals[$roman]); + } + + return $roman; + }, + $title + ); + + return $title; + } + + /** + * "Legend of Zelda, The: A Link to the Past" -> "Legend of Zelda: A Link to the Past" + * + * ", The" has been removed. + */ + private function removeArticles(string $title): string + { + if (preg_match('/^(.+),\s*(a|an|the)\b(.*)$/i', $title, $matches)) { + // Combine the main title and any trailing text. + $mainTitle = trim($matches[1] . $matches[3]); + + return $mainTitle; + } + + return $title; + } + /** - * Sort titles ensure games in lists are sorted properly. * For titles starting with "~", the sort order is determined by the content * within the "~" markers followed by the content after the "~". This ensures * that titles with "~" are grouped together and sorted alphabetically based * on their designated categories and then by their actual game title. - * - * The "~" prefix is retained in the SortTitle of games with "~" to ensure these - * games are sorted at the end of the list, maintaining a clear separation from - * non-prefixed titles. This approach allows game titles to be grouped and sorted - * in a specific order: - * - * 1. Non-prefixed titles are sorted alphabetically at the beginning of the list. - * 2. Titles prefixed with "~" are grouped at the end, sorted first by the category - * specified within the "~" markers, and then alphabetically by the title following - * the "~". */ - public function execute(string $gameTitle): string + private function fixTagTildes(string $title): string { - $sortTitle = mb_strtolower($gameTitle); - - if ($sortTitle[0] === '~') { - $endOfFirstTilde = strpos($sortTitle, '~', 1); + if ($title[0] === '~') { + $endOfFirstTilde = strpos($title, '~', 1); if ($endOfFirstTilde !== false) { - $withinTildes = substr($sortTitle, 1, $endOfFirstTilde - 1); - $afterTildes = trim(substr($sortTitle, $endOfFirstTilde + 1)); + $withinTildes = substr($title, 1, $endOfFirstTilde - 1); + $afterTildes = trim(substr($title, $endOfFirstTilde + 1)); - $sortTitle = '~' . $withinTildes . ' ' . $afterTildes; + $title = '~' . $withinTildes . ' ' . $afterTildes; } } - return $sortTitle; + return $title; } } diff --git a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php index 8763de35f2..8404231688 100644 --- a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php +++ b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php @@ -42,7 +42,7 @@ public function testItPreservesCustomSortTitlesByDefault(): void public function testItCanBeConfiguredToOverrideCustomSortTitles(): void { // Arrange - $game = Game::factory()->create(['Title' => 'Final Fantasy IV', 'sort_title' => 'final fantasy 04']); + $game = Game::factory()->create(['Title' => 'Final Fantasy IV', 'sort_title' => 'final fantasy 0004']); // Act (new WriteGameSortTitleFromGameTitleAction())->execute( @@ -53,19 +53,95 @@ public function testItCanBeConfiguredToOverrideCustomSortTitles(): void $game = $game->fresh(); // Assert - $this->assertEquals('final fantasy iv', $game->sort_title); + $this->assertEquals('final fantasy 04', $game->sort_title); } public function testItCorrectlyHandlesGameTitlesWithTildes(): void { // Arrange - $game = Game::factory()->create(['Title' => '~Homebrew~ Classic Kong', 'sort_title' => null]); + $gameOne = Game::factory()->create(['Title' => '~Homebrew~ Classic Kong', 'sort_title' => null]); + $gameTwo = Game::factory()->create(['Title' => 'Puyo Puyo~n', 'sort_title' => null]); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); + (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); + $gameOne = $gameOne->fresh(); + $gameTwo = $gameTwo->fresh(); + + // Assert + $this->assertEquals('~homebrew classic kong', $gameOne->sort_title); + $this->assertEquals('puyo puyo~n', $gameTwo->sort_title); + } + + public function testItCorrectlyConvertsRomanNumerals(): void + { + // Arrange + $gameOne = Game::factory()->create(['Title' => 'Final Fantasy IV', 'sort_title' => null]); + $gameTwo = Game::factory()->create(['Title' => 'Final Fantasy X', 'sort_title' => null]); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); + (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); + $gameOne = $gameOne->fresh(); + $gameTwo = $gameTwo->fresh(); + + // Assert + $this->assertEquals('final fantasy 04', $gameOne->sort_title); + $this->assertEquals('final fantasy 10', $gameTwo->sort_title); + } + + public function testItAvoidsNonRomanStrings(): void + { + // Arrange + $game = Game::factory()->create(['Title' => 'GIVX', 'sort_title' => null]); // Act (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); $game = $game->fresh(); // Assert - $this->assertEquals('~homebrew classic kong', $game->sort_title); + $this->assertEquals('givx', $game->sort_title); + } + + public function testItNormalizesArticleFragments(): void + { + // Arrange + $gameOne = Game::factory()->create(['Title' => 'Legend of Zelda, The: A Link to the Past', 'sort_title' => null]); + $gameTwo = Game::factory()->create(['Title' => 'American Tale, An', 'sort_title' => null]); + $gameThree = Game::factory()->create(['Title' => 'Grand Day Out, A', 'sort_title' => null]); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); + (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); + (new WriteGameSortTitleFromGameTitleAction())->execute($gameThree, $gameThree->title); + $gameOne = $gameOne->fresh(); + $gameTwo = $gameTwo->fresh(); + $gameThree = $gameThree->fresh(); + + // Assert + $this->assertEquals('legend of zelda: a link to the past', $gameOne->sort_title); + $this->assertEquals('american tale', $gameTwo->sort_title); + $this->assertEquals('grand day out', $gameThree->sort_title); + } + + public function testItAvoidsNonArticleFragments(): void + { + // Arrange + $gameOne = Game::factory()->create(['Title' => 'The Great Escape', 'sort_title' => null]); + $gameTwo = Game::factory()->create(['Title' => 'An Unexpected Journey', 'sort_title' => null]); + $gameThree = Game::factory()->create(['Title' => 'A Series of Unfortunate Events', 'sort_title' => null]); + + // Act + (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); + (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); + (new WriteGameSortTitleFromGameTitleAction())->execute($gameThree, $gameThree->title); + $gameOne = $gameOne->fresh(); + $gameTwo = $gameTwo->fresh(); + $gameThree = $gameThree->fresh(); + + // Assert + $this->assertEquals('the great escape', $gameOne->sort_title); + $this->assertEquals('an unexpected journey', $gameTwo->sort_title); + $this->assertEquals('a series of unfortunate events', $gameThree->sort_title); } } From 7c823c444a477d83295f399511da65bf5f0255be Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 15 Sep 2024 22:25:35 -0400 Subject: [PATCH 08/13] fix: address pr feedback --- .../Actions/ComputeGameSortTitleAction.php | 6 +- ...teGameSortTitleFromGameTitleActionTest.php | 126 +++++------------- 2 files changed, 38 insertions(+), 94 deletions(-) diff --git a/app/Platform/Actions/ComputeGameSortTitleAction.php b/app/Platform/Actions/ComputeGameSortTitleAction.php index ff7f24939e..3f1daf5ddb 100644 --- a/app/Platform/Actions/ComputeGameSortTitleAction.php +++ b/app/Platform/Actions/ComputeGameSortTitleAction.php @@ -29,8 +29,12 @@ private function replaceRomanNumerals(string $title): string 'XVI' => 16, 'XVII' => 17, 'XVIII' => 18, 'XIX' => 19, 'XX' => 20, ]; + /** + * Valid conversions are at the end of a string, or followed by + * non-apostrophe punctuation (or whitespace then punctuation) like :, -, &, or (. + */ $title = preg_replace_callback( - '/\b([IVX]+)\b/', + '/\b([IVX]+)(?=(?:$|\s*[:\-&\(\)]|\s*$))/i', function ($matches) use ($romanNumerals) { $roman = $matches[1]; if (isset($romanNumerals[$roman])) { diff --git a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php index 8404231688..a87032dae2 100644 --- a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php +++ b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php @@ -13,17 +13,46 @@ class WriteGameSortTitleFromGameTitleActionTest extends TestCase { use RefreshDatabase; - public function testItGeneratesSortTitleBasedOnGameTitle(): void + /** + * @dataProvider titleProvider + */ + public function testItGeneratesCorrectSortTitles(string $gameTitle, string $expectedSortTitle): void { // Arrange - $game = Game::factory()->create(['Title' => 'Sonic the Hedgehog', 'sort_title' => null]); + $game = Game::factory()->create(['Title' => $gameTitle, 'sort_title' => null]); // Act (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); - $game = $game->fresh(); + $game->refresh(); // Assert - $this->assertEquals('sonic the hedgehog', $game->sort_title); + $this->assertEquals($expectedSortTitle, $game->sort_title); + } + + /** + * @return string[][] + */ + public static function titleProvider(): array + { + return [ + 'Sonic the Hedgehog' => ['Sonic the Hedgehog', 'sonic the hedgehog'], + 'Final Fantasy IV' => ['Final Fantasy IV', 'final fantasy 04'], + 'Final Fantasy X' => ['Final Fantasy X', 'final fantasy 10'], + 'GIVX' => ['GIVX', 'givx'], + 'Legend of Zelda, The: A Link to the Past' => ['Legend of Zelda, The: A Link to the Past', 'legend of zelda: a link to the past'], + 'American Tale, An' => ['American Tale, An', 'american tale'], + 'Grand Day Out, A' => ['Grand Day Out, A', 'grand day out'], + 'The Great Escape' => ['The Great Escape', 'the great escape'], + 'An Unexpected Journey' => ['An Unexpected Journey', 'an unexpected journey'], + 'A Series of Unfortunate Events' => ['A Series of Unfortunate Events', 'a series of unfortunate events'], + '~Homebrew~ Classic Kong' => ['~Homebrew~ Classic Kong', '~homebrew classic kong'], + 'Puyo Puyo~n' => ['Puyo Puyo~n', 'puyo puyo~n'], + '~Hack~ V I T A L I T Y' => ['~Hack~ V I T A L I T Y', '~hack v i t a l i t y'], + '~Hack~ Dragoon X Omega' => ['~Hack~ Dragoon X Omega', '~hack dragoon x omega'], + '~Hack~ Pokemon - X and Y' => ['~Hack~ Pokemon - X and Y', '~hack pokemon - x and y'], + 'I Have No Mouth, And I Must Scream' => ['I Have No Mouth, And I Must Scream', 'i have no mouth, and i must scream'], + "I'm Sorry" => ["I'm Sorry", "i'm sorry"], + ]; } public function testItPreservesCustomSortTitlesByDefault(): void @@ -55,93 +84,4 @@ public function testItCanBeConfiguredToOverrideCustomSortTitles(): void // Assert $this->assertEquals('final fantasy 04', $game->sort_title); } - - public function testItCorrectlyHandlesGameTitlesWithTildes(): void - { - // Arrange - $gameOne = Game::factory()->create(['Title' => '~Homebrew~ Classic Kong', 'sort_title' => null]); - $gameTwo = Game::factory()->create(['Title' => 'Puyo Puyo~n', 'sort_title' => null]); - - // Act - (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); - (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); - $gameOne = $gameOne->fresh(); - $gameTwo = $gameTwo->fresh(); - - // Assert - $this->assertEquals('~homebrew classic kong', $gameOne->sort_title); - $this->assertEquals('puyo puyo~n', $gameTwo->sort_title); - } - - public function testItCorrectlyConvertsRomanNumerals(): void - { - // Arrange - $gameOne = Game::factory()->create(['Title' => 'Final Fantasy IV', 'sort_title' => null]); - $gameTwo = Game::factory()->create(['Title' => 'Final Fantasy X', 'sort_title' => null]); - - // Act - (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); - (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); - $gameOne = $gameOne->fresh(); - $gameTwo = $gameTwo->fresh(); - - // Assert - $this->assertEquals('final fantasy 04', $gameOne->sort_title); - $this->assertEquals('final fantasy 10', $gameTwo->sort_title); - } - - public function testItAvoidsNonRomanStrings(): void - { - // Arrange - $game = Game::factory()->create(['Title' => 'GIVX', 'sort_title' => null]); - - // Act - (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); - $game = $game->fresh(); - - // Assert - $this->assertEquals('givx', $game->sort_title); - } - - public function testItNormalizesArticleFragments(): void - { - // Arrange - $gameOne = Game::factory()->create(['Title' => 'Legend of Zelda, The: A Link to the Past', 'sort_title' => null]); - $gameTwo = Game::factory()->create(['Title' => 'American Tale, An', 'sort_title' => null]); - $gameThree = Game::factory()->create(['Title' => 'Grand Day Out, A', 'sort_title' => null]); - - // Act - (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); - (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); - (new WriteGameSortTitleFromGameTitleAction())->execute($gameThree, $gameThree->title); - $gameOne = $gameOne->fresh(); - $gameTwo = $gameTwo->fresh(); - $gameThree = $gameThree->fresh(); - - // Assert - $this->assertEquals('legend of zelda: a link to the past', $gameOne->sort_title); - $this->assertEquals('american tale', $gameTwo->sort_title); - $this->assertEquals('grand day out', $gameThree->sort_title); - } - - public function testItAvoidsNonArticleFragments(): void - { - // Arrange - $gameOne = Game::factory()->create(['Title' => 'The Great Escape', 'sort_title' => null]); - $gameTwo = Game::factory()->create(['Title' => 'An Unexpected Journey', 'sort_title' => null]); - $gameThree = Game::factory()->create(['Title' => 'A Series of Unfortunate Events', 'sort_title' => null]); - - // Act - (new WriteGameSortTitleFromGameTitleAction())->execute($gameOne, $gameOne->title); - (new WriteGameSortTitleFromGameTitleAction())->execute($gameTwo, $gameTwo->title); - (new WriteGameSortTitleFromGameTitleAction())->execute($gameThree, $gameThree->title); - $gameOne = $gameOne->fresh(); - $gameTwo = $gameTwo->fresh(); - $gameThree = $gameThree->fresh(); - - // Assert - $this->assertEquals('the great escape', $gameOne->sort_title); - $this->assertEquals('an unexpected journey', $gameTwo->sort_title); - $this->assertEquals('a series of unfortunate events', $gameThree->sort_title); - } } From 187c45d36761ae3a57080367b0591a3f4ed4aca4 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Mon, 16 Sep 2024 11:14:49 -0400 Subject: [PATCH 09/13] feat: get even more ambitious --- .../Actions/ComputeGameSortTitleAction.php | 33 ++++++++- .../Action/ComputeGameSortTitleActionTest.php | 70 +++++++++++++++++++ ...teGameSortTitleFromGameTitleActionTest.php | 35 +--------- 3 files changed, 105 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php diff --git a/app/Platform/Actions/ComputeGameSortTitleAction.php b/app/Platform/Actions/ComputeGameSortTitleAction.php index 3f1daf5ddb..71eb943247 100644 --- a/app/Platform/Actions/ComputeGameSortTitleAction.php +++ b/app/Platform/Actions/ComputeGameSortTitleAction.php @@ -4,18 +4,41 @@ namespace App\Platform\Actions; +use Transliterator; + class ComputeGameSortTitleAction { public function execute(string $gameTitle): string { - $sortTitle = $this->replaceRomanNumerals($gameTitle); + $sortTitle = $this->padNumbers($gameTitle); + $sortTitle = $this->replaceRomanNumerals($sortTitle); $sortTitle = $this->removeArticles($sortTitle); $sortTitle = mb_strtolower($sortTitle); + $sortTitle = $this->normalizeAccents($sortTitle); $sortTitle = $this->fixTagTildes($sortTitle); return $sortTitle; } + /** + * Pad Arabic numbers with leading zeroes to ensure proper sorting. + * + * eg: "Mega Man 10" should not be sorted before "Mega Man 2". With the sort titles + * set to "mega man 00010" and "mega man 00002" respectively, we can mitigate this. + */ + private function padNumbers(string $title): string + { + return preg_replace_callback( + // Match numbers not directly attached to letters. + // In other words, don't convert "Mega Man X4" to "Mega Man X00004". + '/(?<=\b|\s)(\d+)(?=\b|\s)/', + function ($matches) { + return str_pad($matches[0], 5, '0', STR_PAD_LEFT); + }, + $title + ); + } + /** * Replace Roman numerals with their corresponding padded numeric * equivalents (eg: "IV" -> "04"). @@ -66,6 +89,14 @@ private function removeArticles(string $title): string return $title; } + /** + * "Pokémon Stadium" -> "Pokemon Stadium" + */ + private function normalizeAccents(string $title): string + { + return Transliterator::create('NFD; [:Nonspacing Mark:] Remove; NFC')->transliterate($title); + } + /** * For titles starting with "~", the sort order is determined by the content * within the "~" markers followed by the content after the "~". This ensures diff --git a/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php b/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php new file mode 100644 index 0000000000..11dc00d3ff --- /dev/null +++ b/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php @@ -0,0 +1,70 @@ +execute($gameTitle); + + // Assert + $this->assertEquals($expectedSortTitle, $sortTitle); + } + + /** + * @return string[][] + */ + public static function titleProvider(): array + { + return [ + '~Hack~ Dragoon X Omega' => ['~Hack~ Dragoon X Omega', '~hack dragoon x omega'], + '~Hack~ Pokemon - X and Y' => ['~Hack~ Pokemon - X and Y', '~hack pokemon - x and y'], + '~Hack~ V I T A L I T Y' => ['~Hack~ V I T A L I T Y', '~hack v i t a l i t y'], + '~Homebrew~ Classic Kong' => ['~Homebrew~ Classic Kong', '~homebrew classic kong'], + '007: Agent Under Fire' => ['007: Agent Under Fire', '00007: agent under fire'], + '101 Dalmatians' => ['101 Dalmatians', '00101 dalmatians'], + '50 Cent: Blood on the Sand' => ['50 Cent: Blood on the Sand', '00050 cent: blood on the sand'], + 'A Series of Unfortunate Events' => ['A Series of Unfortunate Events', 'a series of unfortunate events'], + 'American Tale, An' => ['American Tale, An', 'american tale'], + 'An Unexpected Journey' => ['An Unexpected Journey', 'an unexpected journey'], + 'Bravely Défault II' => ['Bravely Défault II', 'bravely default 02'], + 'Final Fantasy IV' => ['Final Fantasy IV', 'final fantasy 04'], + 'Final Fantasy X' => ['Final Fantasy X', 'final fantasy 10'], + 'Formula 1-97' => ['Formula 1-97', 'formula 00001-00097'], + 'GIVX' => ['GIVX', 'givx'], + 'Grand Day Out, A' => ['Grand Day Out, A', 'grand day out'], + 'HALF-LIFE 2' => ['HALF-LIFE 2', 'half-life 00002'], + 'I Have No Mouth, And I Must Scream' => ['I Have No Mouth, And I Must Scream', 'i have no mouth, and i must scream'], + 'Kingdom Hearts HD 1.5 Remix' => ['Kingdom Hearts HD 1.5 Remix', 'kingdom hearts hd 00001.00005 remix'], + 'Kingdom Hearts II Final Mix' => ['Kingdom Hearts II Final Mix', 'kingdom hearts ii final mix'], + 'Legend of Zelda, The: A Link to the Past' => ['Legend of Zelda, The: A Link to the Past', 'legend of zelda: a link to the past'], + 'Mega Man 10' => ['Mega Man 10', 'mega man 00010'], + 'Mega Man 2' => ['Mega Man 2', 'mega man 00002'], + 'Mega Man X4' => ['Mega Man X4', 'mega man x4'], + 'Mega Man ZX Advent' => ['Mega Man ZX Advent', 'mega man zx advent'], + 'Ōkami' => ['Ōkami', 'okami'], + 'Pokémon Stadium' => ['Pokémon Stadium', 'pokemon stadium'], + 'Puyo Puyo~n' => ['Puyo Puyo~n', 'puyo puyo~n'], + 'Shin Megami Tensei: Nocturne' => ['Shin Megami Tensei: Nocturne', 'shin megami tensei: nocturne'], + 'Sonic the Hedgehog' => ['Sonic the Hedgehog', 'sonic the hedgehog'], + 'Star Wars Episode III: Revenge of the Sith' => ['Star Wars Episode III: Revenge of the Sith', 'star wars episode 03: revenge of the sith'], + 'Super Mario 3D Land' => ['Super Mario 3D Land', 'super mario 3d land'], + 'Super Mario 64 DS' => ['Super Mario 64 DS', 'super mario 00064 ds'], + 'Super Mario Bros. 3' => ['Super Mario Bros. 3', 'super mario bros. 00003'], + 'The Great Escape' => ['The Great Escape', 'the great escape'], + 'The Matrix Reloaded' => ['The Matrix Reloaded', 'the matrix reloaded'], + "I'm Sorry" => ["I'm Sorry", "i'm sorry"], + "Luigi's Mansion" => ["Luigi's Mansion", "luigi's mansion"], + ]; + } +} diff --git a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php index a87032dae2..18cbda9b61 100644 --- a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php +++ b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php @@ -13,46 +13,17 @@ class WriteGameSortTitleFromGameTitleActionTest extends TestCase { use RefreshDatabase; - /** - * @dataProvider titleProvider - */ - public function testItGeneratesCorrectSortTitles(string $gameTitle, string $expectedSortTitle): void + public function testItGeneratesCorrectSortTitles(): void { // Arrange - $game = Game::factory()->create(['Title' => $gameTitle, 'sort_title' => null]); + $game = Game::factory()->create(['Title' => 'Sonic the Hedgehog', 'sort_title' => null]); // Act (new WriteGameSortTitleFromGameTitleAction())->execute($game, $game->title); $game->refresh(); // Assert - $this->assertEquals($expectedSortTitle, $game->sort_title); - } - - /** - * @return string[][] - */ - public static function titleProvider(): array - { - return [ - 'Sonic the Hedgehog' => ['Sonic the Hedgehog', 'sonic the hedgehog'], - 'Final Fantasy IV' => ['Final Fantasy IV', 'final fantasy 04'], - 'Final Fantasy X' => ['Final Fantasy X', 'final fantasy 10'], - 'GIVX' => ['GIVX', 'givx'], - 'Legend of Zelda, The: A Link to the Past' => ['Legend of Zelda, The: A Link to the Past', 'legend of zelda: a link to the past'], - 'American Tale, An' => ['American Tale, An', 'american tale'], - 'Grand Day Out, A' => ['Grand Day Out, A', 'grand day out'], - 'The Great Escape' => ['The Great Escape', 'the great escape'], - 'An Unexpected Journey' => ['An Unexpected Journey', 'an unexpected journey'], - 'A Series of Unfortunate Events' => ['A Series of Unfortunate Events', 'a series of unfortunate events'], - '~Homebrew~ Classic Kong' => ['~Homebrew~ Classic Kong', '~homebrew classic kong'], - 'Puyo Puyo~n' => ['Puyo Puyo~n', 'puyo puyo~n'], - '~Hack~ V I T A L I T Y' => ['~Hack~ V I T A L I T Y', '~hack v i t a l i t y'], - '~Hack~ Dragoon X Omega' => ['~Hack~ Dragoon X Omega', '~hack dragoon x omega'], - '~Hack~ Pokemon - X and Y' => ['~Hack~ Pokemon - X and Y', '~hack pokemon - x and y'], - 'I Have No Mouth, And I Must Scream' => ['I Have No Mouth, And I Must Scream', 'i have no mouth, and i must scream'], - "I'm Sorry" => ["I'm Sorry", "i'm sorry"], - ]; + $this->assertEquals('sonic the hedgehog', $game->sort_title); } public function testItPreservesCustomSortTitlesByDefault(): void From 2dcb7ecc53463f4ee9331a9501e23cb191c41c65 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Mon, 16 Sep 2024 13:20:27 -0400 Subject: [PATCH 10/13] fix: only save on title update once --- app/Helpers/database/game.php | 18 +++++++++++++----- .../WriteGameSortTitleFromGameTitleAction.php | 12 +++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/Helpers/database/game.php b/app/Helpers/database/game.php index f389762e63..366120bfe1 100644 --- a/app/Helpers/database/game.php +++ b/app/Helpers/database/game.php @@ -651,14 +651,22 @@ function modifyGameTitle(string $username, int $gameId, string $value): bool } $originalTitle = $game->title; - $game->title = $value; - $game->save(); - // Also, if necessary, update the game's sort title. - (new WriteGameSortTitleFromGameTitleAction())->execute($game, $originalTitle); + $newSortTitle = (new WriteGameSortTitleFromGameTitleAction())->execute( + $game, + $originalTitle, + shouldSaveGame: false, + ); - addArticleComment('Server', ArticleType::GameModification, $gameId, "{$username} changed the game name"); + if ($newSortTitle !== null) { + $game->sort_title = $newSortTitle; + } + + if ($game->isDirty()) { + $game->save(); + addArticleComment('Server', ArticleType::GameModification, $gameId, "{$username} changed the game name"); + } return true; } diff --git a/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php b/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php index 55a79aebe9..caf4f6321e 100644 --- a/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php +++ b/app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php @@ -8,8 +8,12 @@ class WriteGameSortTitleFromGameTitleAction { - public function execute(Game $game, string $originalTitle, bool $shouldRespectCustomSortTitle = true): ?string - { + public function execute( + Game $game, + string $originalTitle, + bool $shouldRespectCustomSortTitle = true, + bool $shouldSaveGame = true, + ): ?string { $computeAction = new ComputeGameSortTitleAction(); // Compute the original sort title based on the original game title. @@ -25,7 +29,9 @@ public function execute(Game $game, string $originalTitle, bool $shouldRespectCu // Otherwise, compute and save the new sort title. $game->sort_title = $computeAction->execute($game->title); - $game->save(); + if ($shouldSaveGame) { + $game->save(); + } return $game->sort_title; } From d05e6337315226add898050f215477c44ca2a3e3 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Mon, 16 Sep 2024 13:32:40 -0400 Subject: [PATCH 11/13] feat: strip punctuation --- .../Actions/ComputeGameSortTitleAction.php | 11 ++++++++++ .../Action/ComputeGameSortTitleActionTest.php | 20 +++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/Platform/Actions/ComputeGameSortTitleAction.php b/app/Platform/Actions/ComputeGameSortTitleAction.php index 71eb943247..762609bdd2 100644 --- a/app/Platform/Actions/ComputeGameSortTitleAction.php +++ b/app/Platform/Actions/ComputeGameSortTitleAction.php @@ -15,6 +15,7 @@ public function execute(string $gameTitle): string $sortTitle = $this->removeArticles($sortTitle); $sortTitle = mb_strtolower($sortTitle); $sortTitle = $this->normalizeAccents($sortTitle); + $sortTitle = $this->stripPunctuation($sortTitle); $sortTitle = $this->fixTagTildes($sortTitle); return $sortTitle; @@ -97,6 +98,16 @@ private function normalizeAccents(string $title): string return Transliterator::create('NFD; [:Nonspacing Mark:] Remove; NFC')->transliterate($title); } + /** + * "Luigi's Mansion" -> "Luigis Mansion" + * "The Legend of Zelda: Link's Awakening" -> "The Legend of Zelda Links Awakening" + */ + private function stripPunctuation(string $title): string + { + // Keep tildes (~) and hyphens (-). + return preg_replace("/[^\w\s~\-]+/u", '', $title); + } + /** * For titles starting with "~", the sort order is determined by the content * within the "~" markers followed by the content after the "~". This ensures diff --git a/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php b/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php index 11dc00d3ff..9f3b720632 100644 --- a/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php +++ b/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php @@ -31,9 +31,9 @@ public static function titleProvider(): array '~Hack~ Pokemon - X and Y' => ['~Hack~ Pokemon - X and Y', '~hack pokemon - x and y'], '~Hack~ V I T A L I T Y' => ['~Hack~ V I T A L I T Y', '~hack v i t a l i t y'], '~Homebrew~ Classic Kong' => ['~Homebrew~ Classic Kong', '~homebrew classic kong'], - '007: Agent Under Fire' => ['007: Agent Under Fire', '00007: agent under fire'], + '007: Agent Under Fire' => ['007: Agent Under Fire', '00007 agent under fire'], '101 Dalmatians' => ['101 Dalmatians', '00101 dalmatians'], - '50 Cent: Blood on the Sand' => ['50 Cent: Blood on the Sand', '00050 cent: blood on the sand'], + '50 Cent: Blood on the Sand' => ['50 Cent: Blood on the Sand', '00050 cent blood on the sand'], 'A Series of Unfortunate Events' => ['A Series of Unfortunate Events', 'a series of unfortunate events'], 'American Tale, An' => ['American Tale, An', 'american tale'], 'An Unexpected Journey' => ['An Unexpected Journey', 'an unexpected journey'], @@ -44,10 +44,10 @@ public static function titleProvider(): array 'GIVX' => ['GIVX', 'givx'], 'Grand Day Out, A' => ['Grand Day Out, A', 'grand day out'], 'HALF-LIFE 2' => ['HALF-LIFE 2', 'half-life 00002'], - 'I Have No Mouth, And I Must Scream' => ['I Have No Mouth, And I Must Scream', 'i have no mouth, and i must scream'], - 'Kingdom Hearts HD 1.5 Remix' => ['Kingdom Hearts HD 1.5 Remix', 'kingdom hearts hd 00001.00005 remix'], + 'I Have No Mouth, And I Must Scream' => ['I Have No Mouth, And I Must Scream', 'i have no mouth and i must scream'], + 'Kingdom Hearts HD 1.5 Remix' => ['Kingdom Hearts HD 1.5 Remix', 'kingdom hearts hd 0000100005 remix'], 'Kingdom Hearts II Final Mix' => ['Kingdom Hearts II Final Mix', 'kingdom hearts ii final mix'], - 'Legend of Zelda, The: A Link to the Past' => ['Legend of Zelda, The: A Link to the Past', 'legend of zelda: a link to the past'], + 'Legend of Zelda, The: A Link to the Past' => ['Legend of Zelda, The: A Link to the Past', 'legend of zelda a link to the past'], 'Mega Man 10' => ['Mega Man 10', 'mega man 00010'], 'Mega Man 2' => ['Mega Man 2', 'mega man 00002'], 'Mega Man X4' => ['Mega Man X4', 'mega man x4'], @@ -55,16 +55,16 @@ public static function titleProvider(): array 'Ōkami' => ['Ōkami', 'okami'], 'Pokémon Stadium' => ['Pokémon Stadium', 'pokemon stadium'], 'Puyo Puyo~n' => ['Puyo Puyo~n', 'puyo puyo~n'], - 'Shin Megami Tensei: Nocturne' => ['Shin Megami Tensei: Nocturne', 'shin megami tensei: nocturne'], + 'Shin Megami Tensei: Nocturne' => ['Shin Megami Tensei: Nocturne', 'shin megami tensei nocturne'], 'Sonic the Hedgehog' => ['Sonic the Hedgehog', 'sonic the hedgehog'], - 'Star Wars Episode III: Revenge of the Sith' => ['Star Wars Episode III: Revenge of the Sith', 'star wars episode 03: revenge of the sith'], + 'Star Wars Episode III: Revenge of the Sith' => ['Star Wars Episode III: Revenge of the Sith', 'star wars episode 03 revenge of the sith'], 'Super Mario 3D Land' => ['Super Mario 3D Land', 'super mario 3d land'], 'Super Mario 64 DS' => ['Super Mario 64 DS', 'super mario 00064 ds'], - 'Super Mario Bros. 3' => ['Super Mario Bros. 3', 'super mario bros. 00003'], + 'Super Mario Bros. 3' => ['Super Mario Bros. 3', 'super mario bros 00003'], 'The Great Escape' => ['The Great Escape', 'the great escape'], 'The Matrix Reloaded' => ['The Matrix Reloaded', 'the matrix reloaded'], - "I'm Sorry" => ["I'm Sorry", "i'm sorry"], - "Luigi's Mansion" => ["Luigi's Mansion", "luigi's mansion"], + "I'm Sorry" => ["I'm Sorry", "im sorry"], + "Luigi's Mansion" => ["Luigi's Mansion", "luigis mansion"], ]; } } From f878ba34dba85fb18389f64b58bdbba35caeef6f Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Tue, 17 Sep 2024 16:38:07 -0400 Subject: [PATCH 12/13] fix: address pr feedback --- .../Actions/ComputeGameSortTitleAction.php | 20 ++++++++++++------- .../Action/ComputeGameSortTitleActionTest.php | 20 +++++++++---------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/app/Platform/Actions/ComputeGameSortTitleAction.php b/app/Platform/Actions/ComputeGameSortTitleAction.php index 762609bdd2..91e6968cc4 100644 --- a/app/Platform/Actions/ComputeGameSortTitleAction.php +++ b/app/Platform/Actions/ComputeGameSortTitleAction.php @@ -10,8 +10,8 @@ class ComputeGameSortTitleAction { public function execute(string $gameTitle): string { - $sortTitle = $this->padNumbers($gameTitle); - $sortTitle = $this->replaceRomanNumerals($sortTitle); + $sortTitle = $this->replaceRomanNumerals($gameTitle); + $sortTitle = $this->padNumbers($sortTitle); $sortTitle = $this->removeArticles($sortTitle); $sortTitle = mb_strtolower($sortTitle); $sortTitle = $this->normalizeAccents($sortTitle); @@ -74,17 +74,23 @@ function ($matches) use ($romanNumerals) { } /** - * "Legend of Zelda, The: A Link to the Past" -> "Legend of Zelda: A Link to the Past" + * Removing leading and trailing articles. * - * ", The" has been removed. + * "The Matrix" -> "Matrix" + * "A Bug's Life" -> "Bug's Life" + * "Legend of Zelda, The" -> "Legend of Zelda" */ private function removeArticles(string $title): string { + // Remove articles at the start of the title + if (preg_match('/^\s*(a|an|the)\b\s*(.*)$/i', $title, $matches)) { + $title = $matches[2]; + } + + // Remove articles at the end after a comma if (preg_match('/^(.+),\s*(a|an|the)\b(.*)$/i', $title, $matches)) { // Combine the main title and any trailing text. - $mainTitle = trim($matches[1] . $matches[3]); - - return $mainTitle; + $title = trim($matches[1] . $matches[3]); } return $title; diff --git a/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php b/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php index 9f3b720632..aca7abb468 100644 --- a/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php +++ b/tests/Feature/Platform/Action/ComputeGameSortTitleActionTest.php @@ -34,20 +34,22 @@ public static function titleProvider(): array '007: Agent Under Fire' => ['007: Agent Under Fire', '00007 agent under fire'], '101 Dalmatians' => ['101 Dalmatians', '00101 dalmatians'], '50 Cent: Blood on the Sand' => ['50 Cent: Blood on the Sand', '00050 cent blood on the sand'], - 'A Series of Unfortunate Events' => ['A Series of Unfortunate Events', 'a series of unfortunate events'], + 'A Series of Unfortunate Events' => ['A Series of Unfortunate Events', 'series of unfortunate events'], 'American Tale, An' => ['American Tale, An', 'american tale'], - 'An Unexpected Journey' => ['An Unexpected Journey', 'an unexpected journey'], - 'Bravely Défault II' => ['Bravely Défault II', 'bravely default 02'], - 'Final Fantasy IV' => ['Final Fantasy IV', 'final fantasy 04'], - 'Final Fantasy X' => ['Final Fantasy X', 'final fantasy 10'], + 'An Unexpected Journey' => ['An Unexpected Journey', 'unexpected journey'], + 'Bravely Défault II' => ['Bravely Défault II', 'bravely default 00002'], + 'Final Fantasy IV' => ['Final Fantasy IV', 'final fantasy 00004'], + 'Final Fantasy X' => ['Final Fantasy X', 'final fantasy 00010'], 'Formula 1-97' => ['Formula 1-97', 'formula 00001-00097'], 'GIVX' => ['GIVX', 'givx'], 'Grand Day Out, A' => ['Grand Day Out, A', 'grand day out'], 'HALF-LIFE 2' => ['HALF-LIFE 2', 'half-life 00002'], 'I Have No Mouth, And I Must Scream' => ['I Have No Mouth, And I Must Scream', 'i have no mouth and i must scream'], + 'I\'m Sorry' => ["I'm Sorry", "im sorry"], 'Kingdom Hearts HD 1.5 Remix' => ['Kingdom Hearts HD 1.5 Remix', 'kingdom hearts hd 0000100005 remix'], 'Kingdom Hearts II Final Mix' => ['Kingdom Hearts II Final Mix', 'kingdom hearts ii final mix'], 'Legend of Zelda, The: A Link to the Past' => ['Legend of Zelda, The: A Link to the Past', 'legend of zelda a link to the past'], + 'Luigi\'s Mansion' => ["Luigi's Mansion", "luigis mansion"], 'Mega Man 10' => ['Mega Man 10', 'mega man 00010'], 'Mega Man 2' => ['Mega Man 2', 'mega man 00002'], 'Mega Man X4' => ['Mega Man X4', 'mega man x4'], @@ -57,14 +59,12 @@ public static function titleProvider(): array 'Puyo Puyo~n' => ['Puyo Puyo~n', 'puyo puyo~n'], 'Shin Megami Tensei: Nocturne' => ['Shin Megami Tensei: Nocturne', 'shin megami tensei nocturne'], 'Sonic the Hedgehog' => ['Sonic the Hedgehog', 'sonic the hedgehog'], - 'Star Wars Episode III: Revenge of the Sith' => ['Star Wars Episode III: Revenge of the Sith', 'star wars episode 03 revenge of the sith'], + 'Star Wars Episode III: Revenge of the Sith' => ['Star Wars Episode III: Revenge of the Sith', 'star wars episode 00003 revenge of the sith'], 'Super Mario 3D Land' => ['Super Mario 3D Land', 'super mario 3d land'], 'Super Mario 64 DS' => ['Super Mario 64 DS', 'super mario 00064 ds'], 'Super Mario Bros. 3' => ['Super Mario Bros. 3', 'super mario bros 00003'], - 'The Great Escape' => ['The Great Escape', 'the great escape'], - 'The Matrix Reloaded' => ['The Matrix Reloaded', 'the matrix reloaded'], - "I'm Sorry" => ["I'm Sorry", "im sorry"], - "Luigi's Mansion" => ["Luigi's Mansion", "luigis mansion"], + 'The Great Escape' => ['The Great Escape', 'great escape'], + 'The Matrix Reloaded' => ['The Matrix Reloaded', 'matrix reloaded'], ]; } } From b0453e5f42b19cc92dc8dc24745e5b7017812e2b Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Tue, 17 Sep 2024 16:40:06 -0400 Subject: [PATCH 13/13] chore: fix test --- .../Action/WriteGameSortTitleFromGameTitleActionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php index 18cbda9b61..9c8af2d97e 100644 --- a/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php +++ b/tests/Feature/Platform/Action/WriteGameSortTitleFromGameTitleActionTest.php @@ -53,6 +53,6 @@ public function testItCanBeConfiguredToOverrideCustomSortTitles(): void $game = $game->fresh(); // Assert - $this->assertEquals('final fantasy 04', $game->sort_title); + $this->assertEquals('final fantasy 00004', $game->sort_title); } }