Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(game): add sort_title field and backfill command #2690

Merged
merged 17 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion app/Filament/Resources/GameResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +90,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}"))
Expand Down Expand Up @@ -238,7 +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.')
Jamiras marked this conversation as resolved.
Show resolved Hide resolved
->reactive()
->afterStateHydrated(function (callable $set, callable $get, string $state) {
$set('original_sort_title', $state);
}),
Jamiras marked this conversation as resolved.
Show resolved Hide resolved

Forms\Components\TextInput::make('ForumTopicID')
->label('Forum Topic ID')
Expand Down
8 changes: 7 additions & 1 deletion app/Helpers/database/game.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Jamiras marked this conversation as resolved.
Show resolved Hide resolved
Jamiras marked this conversation as resolved.
Show resolved Hide resolved

addArticleComment('Server', ArticleType::GameModification, $gameId, "{$username} changed the game name");

return true;
Expand Down
2 changes: 2 additions & 0 deletions app/Models/Game.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class Game extends BaseModel implements HasComments, HasMedia
protected $fillable = [
'release',
'Title',
'sort_title',
'ForumTopicID',
'Publisher',
'Developer',
Expand All @@ -87,6 +88,7 @@ class Game extends BaseModel implements HasComments, HasMedia
protected $visible = [
'ID',
'Title',
'sort_title',
'ConsoleID',
'ForumTopicID',
'Flags',
Expand Down
64 changes: 64 additions & 0 deletions app/Platform/Actions/WriteGameSortTitleFromGameTitleAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace App\Platform\Actions;

use App\Models\Game;

class WriteGameSortTitleFromGameTitleAction
{
public function execute(Game $game, string $originalTitle, bool $shouldRespectCustomSortTitle = true): ?string
wescopeland marked this conversation as resolved.
Show resolved Hide resolved
{
// 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
// if the game indeed has a custom sort title, we may not want to override it.
if ($game->sort_title && $shouldRespectCustomSortTitle) {
$originalSortTitle = $this->computeSortTitle($originalTitle);

if ($game->sort_title !== $originalSortTitle) {
return null;
}
}

// Otherwise, compute and save the new sort title.
$game->sort_title = $this->computeSortTitle($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;
}
}
2 changes: 2 additions & 0 deletions app/Platform/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -73,6 +74,7 @@ public function boot(): void
UpdateGameMetrics::class,
UpdateGameAchievementsMetrics::class,
UpdateGamePlayerGames::class,
WriteGameSortTitles::class,

// Game Hashes
NoIntroImport::class,
Expand Down
58 changes: 58 additions & 0 deletions app/Platform/Commands/WriteGameSortTitles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Platform\Commands;

use App\Models\Game;
use App\Platform\Actions\WriteGameSortTitleFromGameTitleAction;
use Illuminate\Console\Command;

class WriteGameSortTitles extends Command
{
protected $signature = "ra:platform:write-game-sort-titles
{gameId? : Target a single game}";
protected $description = 'Write sort titles for games derived from their canonical titles';

public function __construct()
{
parent::__construct();
}

public function handle(): void
{
$gameId = $this->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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be overly ambitious to re-normalize articles?

Legend of Zelda, The: A Link to the Past => The Legend of Zelda: A Link to the Past with a sort title of just legend of zelda: a link to the past

Bug's Life, A => A Bug's Life with a sort title of bug's life

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tricky, but I think I got it in latest. Added a few tests which seem to verify the new behavior is okay.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, would you like to also see the game titles themselves readjust the placement of the articles?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the goal state. Per #1062:

Ignore definite and indefinite articles at the start of titles so we don't have to rename them to force the correct sorting order.

Doing so now would break the sort order until the queries were updated to use the new sort column.

}

$progressBar->advance(count($games));
});

$progressBar->finish();

$this->info("\nAll sort titles have been upserted.");
}
}
}
1 change: 1 addition & 0 deletions app/Platform/Services/GameListService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Jamiras marked this conversation as resolved.
Show resolved Hide resolved
$game['SortTitle'] = mb_strtolower($game['Title']);
if ($game['SortTitle'][0] === '~') {
$endOfFirstTilde = strpos($game['SortTitle'], '~', 1);
Expand Down
8 changes: 3 additions & 5 deletions app/Policies/GamePolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
wescopeland marked this conversation as resolved.
Show resolved Hide resolved
];

$userRoles = $user->getRoleNames();
Expand Down Expand Up @@ -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() ?
Expand All @@ -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
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class() extends Migration {
public function up(): void
{
Schema::table('GameData', function (Blueprint $table) {
$table->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');
Jamiras marked this conversation as resolved.
Show resolved Hide resolved
});

Schema::table('GameData', function (Blueprint $table) {
$table->dropColumn('sort_title');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace Tests\Feature\Platform\Action;

use App\Models\Game;
use App\Platform\Actions\WriteGameSortTitleFromGameTitleAction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class WriteGameSortTitleFromGameTitleActionTest extends TestCase
{
use RefreshDatabase;

public function testItGeneratesSortTitleBasedOnGameTitle(): void
{
// Arrange
$game = Game::factory()->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);
}
Jamiras marked this conversation as resolved.
Show resolved Hide resolved
}