diff --git a/app/Community/Actions/FetchDynamicShortcodeContentAction.php b/app/Community/Actions/FetchDynamicShortcodeContentAction.php index f1298183ed..4ff9c70969 100644 --- a/app/Community/Actions/FetchDynamicShortcodeContentAction.php +++ b/app/Community/Actions/FetchDynamicShortcodeContentAction.php @@ -29,6 +29,7 @@ public function execute( array $gameIds = [], array $hubIds = [], array $eventIds = [], + string $convertedBody = '', ): ShortcodeDynamicEntitiesData { return new ShortcodeDynamicEntitiesData( users: $this->fetchUsers($usernames)->all(), @@ -37,6 +38,7 @@ public function execute( games: $this->fetchGames($gameIds)->all(), hubs: $this->fetchHubs($hubIds)->all(), events: $this->fetchEvents($eventIds)->all(), + convertedBody: $convertedBody, ); } diff --git a/app/Community/Controllers/Api/ForumTopicCommentApiController.php b/app/Community/Controllers/Api/ForumTopicCommentApiController.php index ce1cdaeda6..0d8dee74de 100644 --- a/app/Community/Controllers/Api/ForumTopicCommentApiController.php +++ b/app/Community/Controllers/Api/ForumTopicCommentApiController.php @@ -69,6 +69,9 @@ public function update( // Convert [game={legacy_hub_id}] to [hub={game_set_id}]. $newPayload = Shortcode::convertLegacyGameHubShortcodesToHubShortcodes($newPayload); + // Convert [game=X?set=Y] to [game=backingGameId]. + $newPayload = Shortcode::convertGameSetShortcodesToBackingGame($newPayload); + $comment->body = $newPayload; // If this post is being edited by someone other than diff --git a/app/Community/Controllers/Api/ShortcodeApiController.php b/app/Community/Controllers/Api/ShortcodeApiController.php index ec16695843..9f0c60acdb 100644 --- a/app/Community/Controllers/Api/ShortcodeApiController.php +++ b/app/Community/Controllers/Api/ShortcodeApiController.php @@ -7,6 +7,7 @@ use App\Community\Actions\FetchDynamicShortcodeContentAction; use App\Community\Requests\PreviewShortcodeBodyRequest; use App\Http\Controller; +use App\Support\Shortcode\Shortcode; use Illuminate\Http\JsonResponse; class ShortcodeApiController extends Controller @@ -15,13 +16,26 @@ public function preview( PreviewShortcodeBodyRequest $request, FetchDynamicShortcodeContentAction $action, ): JsonResponse { + $body = $request->input('body'); + + // Normalize URLs to shortcode format (eg: "https://retroachievements.org/game/123" -> "[game=123]"). + $body = normalize_shortcodes($body); + + // Convert [game=X?set=Y] shortcodes to their backing game IDs. + $body = Shortcode::convertGameSetShortcodesToBackingGame($body); + + // Extract entity IDs from the normalized+converted body. + $extractedIds = Shortcode::extractShortcodeIds($body); + + // Fetch the entities and return the final converted body. $entities = $action->execute( - usernames: $request->input('usernames'), - ticketIds: $request->input('ticketIds'), - achievementIds: $request->input('achievementIds'), - gameIds: $request->input('gameIds'), - hubIds: $request->input('hubIds'), - eventIds: $request->input('eventIds'), + convertedBody: $body, + achievementIds: $extractedIds['achievementIds'], + eventIds: $extractedIds['eventIds'], + gameIds: $extractedIds['gameIds'], + hubIds: $extractedIds['hubIds'], + ticketIds: $extractedIds['ticketIds'], + usernames: $extractedIds['usernames'], ); return response()->json($entities); diff --git a/app/Community/Data/ShortcodeDynamicEntitiesData.php b/app/Community/Data/ShortcodeDynamicEntitiesData.php index 1822d7aa79..ad322f073d 100644 --- a/app/Community/Data/ShortcodeDynamicEntitiesData.php +++ b/app/Community/Data/ShortcodeDynamicEntitiesData.php @@ -31,6 +31,7 @@ public function __construct( public array $games = [], public array $hubs = [], public array $events = [], + public string $convertedBody = '', ) { } } diff --git a/app/Community/Requests/PreviewShortcodeBodyRequest.php b/app/Community/Requests/PreviewShortcodeBodyRequest.php index 8f943ea7c0..34764666ed 100644 --- a/app/Community/Requests/PreviewShortcodeBodyRequest.php +++ b/app/Community/Requests/PreviewShortcodeBodyRequest.php @@ -11,16 +11,7 @@ class PreviewShortcodeBodyRequest extends FormRequest public function rules(): array { return [ - 'usernames' => 'present|array', - 'usernames.*' => 'string', - 'ticketIds' => 'present|array', - 'ticketIds.*' => 'integer', - 'achievementIds' => 'present|array', - 'achievementIds.*' => 'integer', - 'gameIds' => 'present|array', - 'gameIds.*' => 'integer', - 'hubIds' => 'present|array', - 'hubIds.*' => 'integer', + 'body' => 'required|string', ]; } } diff --git a/app/Helpers/database/forum.php b/app/Helpers/database/forum.php index a85fe35ff6..9183b69dab 100644 --- a/app/Helpers/database/forum.php +++ b/app/Helpers/database/forum.php @@ -163,6 +163,9 @@ function editTopicComment(int $commentId, string $newPayload): void // Convert [game={legacy_hub_id}] to [hub={game_set_id}]. $newPayload = Shortcode::convertLegacyGameHubShortcodesToHubShortcodes($newPayload); + // Convert [game=X?set=Y] to [game=backingGameId]. + $newPayload = Shortcode::convertGameSetShortcodesToBackingGame($newPayload); + $comment = ForumTopicComment::findOrFail($commentId); $comment->body = $newPayload; $comment->save(); @@ -190,6 +193,9 @@ function submitTopicComment( // Convert [game={legacy_hub_id}] to [hub={game_set_id}]. $commentPayload = Shortcode::convertLegacyGameHubShortcodesToHubShortcodes($commentPayload); + // Convert [game=X?set=Y] to [game=backingGameId]. + $commentPayload = Shortcode::convertGameSetShortcodesToBackingGame($commentPayload); + // if this exact message was just posted by this user, assume it's an // accidental double submission and ignore. $latestPost = $user->forumPosts()->latest('created_at')->first(); diff --git a/app/Helpers/shortcode.php b/app/Helpers/shortcode.php index 50ef2e8690..04b611c92a 100644 --- a/app/Helpers/shortcode.php +++ b/app/Helpers/shortcode.php @@ -96,12 +96,25 @@ function normalize_shortcodes(string $input): string function normalize_targeted_shortcodes(string $input, string $kind, ?string $tagName = null): string { // Find any URL variants of user links and transform them into shortcode tags. + // First, handle URLs with a ?set= query param (these are games). + if ($kind === 'game') { + $findWithSet = [ + "~https?://(?:[\w\-]+\.)?retroachievements\.org/game2?/([\w]{1,20})(?:-[^\s\"'<>]*)?(?:/)?\\?set=(\d+)~si", + "~https?://localhost(?::\d{1,5})?/game2?/([\w]{1,20})(?:-[^\s\"'<>]*)?(?:/)?\\?set=(\d+)~si", + ]; + $replaceWithSet = "[game=$1?set=$2]"; + $input = preg_replace($findWithSet, $replaceWithSet, $input); + } + + // Then, handle URLs without query params. // Ignore URLs that contain path or query params. + // For games, match both /game/ and /game2/ URLs. + $pathPattern = $kind === 'game' ? 'game2?' : $kind; $find = [ - "~\]*retroachievements\.org/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^/>]*\][^]*~si", - "~\[url[^\]]*retroachievements\.org/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^\]]*\][^\[]*\[/url\]~si", - "~https?://(?:[\w\-]+\.)?retroachievements\.org/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si", - "~https?://localhost(?::\d{1,5})?/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si", + "~\]*retroachievements\.org/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^/>]*\][^]*~si", + "~\[url[^\]]*retroachievements\.org/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^\]]*\][^\[]*\[/url\]~si", + "~https?://(?:[\w\-]+\.)?retroachievements\.org/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si", + "~https?://localhost(?::\d{1,5})?/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si", ]; $replace = "[" . ($tagName ?? $kind) . "=$1]"; diff --git a/app/Support/Shortcode/Shortcode.php b/app/Support/Shortcode/Shortcode.php index 46f25b7397..7be81a3aec 100644 --- a/app/Support/Shortcode/Shortcode.php +++ b/app/Support/Shortcode/Shortcode.php @@ -11,6 +11,7 @@ use App\Models\System; use App\Models\Ticket; use App\Models\User; +use App\Platform\Actions\ResolveBackingGameForAchievementSetAction; use App\Platform\Enums\GameSetType; use Carbon\Carbon; use Illuminate\Support\Facades\Cache; @@ -128,6 +129,81 @@ public static function convertUserShortcodesToUseIds(string $input): string }, $input); } + public static function convertGameSetShortcodesToBackingGame(string $input): string + { + // Extract all [game=X?set=Y] or [game=X set=Y] patterns. + preg_match_all('/\[game=(\d+)(?:\?|\s)set=(\d+)\]/i', $input, $matches, PREG_SET_ORDER); + if (empty($matches)) { + return $input; + } + + // Collect all unique set IDs for bulk processing. + $setIds = array_unique(array_column($matches, 2)); + + // Now, resolve backing games for all sets. + // Build a map of achievement set ID -> backing game ID. + $setToBackingGameMap = []; + $resolveAction = (new ResolveBackingGameForAchievementSetAction()); + + foreach ($setIds as $setId) { + $backingGameId = $resolveAction->execute((int) $setId); + if ($backingGameId) { + $setToBackingGameMap[$setId] = $backingGameId; + } + } + + // Replace each shortcode with the backing game ID. + // If no backing game is found, fall back to the original game ID. + return preg_replace_callback( + '/\[game=(\d+)(?:\?|\s)set=(\d+)\]/i', + function ($match) use ($setToBackingGameMap) { + $originalGameId = $match[1]; + $setId = $match[2]; + + $gameId = $setToBackingGameMap[$setId] ?? $originalGameId; + + return "[game={$gameId}]"; + }, + $input + ); + } + + public static function extractShortcodeIds(string $input): array + { + // Extract achievement IDs from [ach=X] shortcodes. + preg_match_all('/\[ach=(\d+)\]/i', $input, $achievementMatches); + $achievementIds = array_unique(array_map('intval', $achievementMatches[1])); + + // Extract game IDs from [game=X] shortcodes (but not [game=X set=Y]). + preg_match_all('/\[game=(\d+)(?!\?|\ set=)\]/i', $input, $gameMatches); + $gameIds = array_unique(array_map('intval', $gameMatches[1])); + + // Extract hub IDs from [hub=X] shortcodes. + preg_match_all('/\[hub=(\d+)\]/i', $input, $hubMatches); + $hubIds = array_unique(array_map('intval', $hubMatches[1])); + + // Extract event IDs from [event=X] shortcodes. + preg_match_all('/\[event=(\d+)\]/i', $input, $eventMatches); + $eventIds = array_unique(array_map('intval', $eventMatches[1])); + + // Extract ticket IDs from [ticket=X] shortcodes. + preg_match_all('/\[ticket=(\d+)\]/i', $input, $ticketMatches); + $ticketIds = array_unique(array_map('intval', $ticketMatches[1])); + + // Extract usernames from [user=X] shortcodes. + preg_match_all('/\[user=([^\]]+)\]/i', $input, $userMatches); + $usernames = array_unique($userMatches[1]); + + return [ + 'achievementIds' => array_values($achievementIds), + 'gameIds' => array_values($gameIds), + 'hubIds' => array_values($hubIds), + 'eventIds' => array_values($eventIds), + 'ticketIds' => array_values($ticketIds), + 'usernames' => $usernames, + ]; + } + public static function stripAndClamp( string $input, int $previewLength = 100, @@ -476,6 +552,10 @@ private function prefetchUsers(string $input): void private function parse(string $input, array $options = []): string { + // Resolve game set shortcodes to backing games before render-time. + // This allows [game=1?set=9534] to display the correct backing game. + $input = self::convertGameSetShortcodesToBackingGame($input); + $this->prefetchUsers($input); // make sure to use attribute delimiter for string values diff --git a/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts b/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts index 850e886a8a..2a44ad27a4 100644 --- a/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts +++ b/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts @@ -2,17 +2,13 @@ import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; import { route } from 'ziggy-js'; -import type { - DynamicShortcodeEntities, - ShortcodeBodyPreviewMutationResponse, -} from '@/common/models'; +import type { ShortcodeBodyPreviewMutationResponse } from '@/common/models'; export function useShortcodeBodyPreviewMutation() { return useMutation({ - mutationFn: (payload: DynamicShortcodeEntities) => - axios.post( - route('api.shortcode-body.preview'), - payload, - ), + mutationFn: (body: string) => + axios.post(route('api.shortcode-body.preview'), { + body, + }), }); } diff --git a/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts b/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts index b6d86b78cd..44cbc6bfc7 100644 --- a/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts +++ b/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts @@ -31,6 +31,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => { events: [], tickets: [], users: [], + convertedBody: '', }; // ACT @@ -45,6 +46,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => { events: [], tickets: [], users: [], + convertedBody: '', }, }, }, @@ -65,6 +67,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => { hubs: [{ id: 3 }], tickets: [{ id: 4 }], users: [{ id: 5 }], + convertedBody: '', }; // ACT @@ -77,6 +80,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => { events: [], tickets: [], users: [], + convertedBody: '', }, }, }); diff --git a/resources/js/common/hooks/useShortcodeBodyPreview.test.ts b/resources/js/common/hooks/useShortcodeBodyPreview.test.ts index a3169648b3..1d228bbf5d 100644 --- a/resources/js/common/hooks/useShortcodeBodyPreview.test.ts +++ b/resources/js/common/hooks/useShortcodeBodyPreview.test.ts @@ -22,13 +22,23 @@ describe('Hook: useShortcodeBodyPreview', () => { expect(result.current).toBeTruthy(); }); - it('given content with no dynamic entities, sets preview content without making an API call', async () => { + it('given content with no dynamic entities, makes an API call with body text', async () => { // ARRANGE - const postSpy = vi.spyOn(axios, 'post'); + const simpleContent = 'Hello world!'; - const { result } = renderHook(() => useShortcodeBodyPreview()); + const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: simpleContent, + }, + }); - const simpleContent = 'Hello world!'; + const { result } = renderHook(() => useShortcodeBodyPreview()); // ACT await act(async () => { @@ -36,7 +46,7 @@ describe('Hook: useShortcodeBodyPreview', () => { }); // ASSERT - expect(postSpy).not.toHaveBeenCalled(); + expect(postSpy).toHaveBeenCalledWith(['api.shortcode-body.preview'], { body: simpleContent }); await waitFor(() => { expect(result.current.previewContent).toEqual(simpleContent); @@ -54,6 +64,7 @@ describe('Hook: useShortcodeBodyPreview', () => { events: [], tickets: [], users: [createUser({ displayName: 'username' })], + convertedBody: '[user=username] and [game=123]', }, }; @@ -68,7 +79,7 @@ describe('Hook: useShortcodeBodyPreview', () => { // ASSERT await waitFor(() => { - expect(result.current.previewContent).toEqual(contentWithEntities); + expect(result.current.previewContent).toEqual('[user=username] and [game=123]'); // !! uses converted body }); }); @@ -88,6 +99,7 @@ describe('Hook: useShortcodeBodyPreview', () => { ], tickets: [createTicket({ id: 12345 })], users: [createUser({ displayName: 'username' })], + convertedBody: '[user=username] and [game=123]', }, }; @@ -108,7 +120,7 @@ describe('Hook: useShortcodeBodyPreview', () => { // ASSERT await waitFor(() => { - expect(result.current.previewContent).toEqual(contentWithEntities); + expect(result.current.previewContent).toEqual('[user=username] and [game=123]'); }); const { @@ -134,4 +146,205 @@ describe('Hook: useShortcodeBodyPreview', () => { expect(persistedTickets).toHaveLength(1); expect(persistedUsers).toHaveLength(1); }); + + it('given content with a game set shortcode, converts to backing game ID in the preview', async () => { + // ARRANGE + const contentWithSetId = 'Check out [game=668?set=8659]'; + + const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [createGame({ id: 29895, title: 'Sonic the Hedgehog [Subset - Perfect Bonus]' })], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'Check out [game=29895]', + }, + }); + + const { result } = renderHook(() => useShortcodeBodyPreview()); + + // ACT + await act(async () => { + await result.current.initiatePreview(contentWithSetId); + }); + + // ASSERT + await waitFor(() => { + expect(postSpy).toHaveBeenCalledWith(['api.shortcode-body.preview'], { + body: contentWithSetId, + }); + }); + + await waitFor(() => { + expect(result.current.previewContent).toEqual('Check out [game=29895]'); + }); + + const { persistedGames } = result.current.unsafe_getPersistedValues(); + expect(persistedGames).toHaveLength(1); + expect(persistedGames).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 29895 })]), + ); + }); + + it('given content with multiple game set shortcodes, converts all to backing game IDs', async () => { + // ARRANGE + const contentWithSetIds = 'Try [game=668?set=8659] and [game=1?set=9534]'; + + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [ + createGame({ id: 29895, title: 'Sonic the Hedgehog [Subset - Perfect Bonus]' }), + createGame({ id: 28000, title: 'Another Subset Game' }), + ], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'Try [game=29895] and [game=28000]', + }, + }); + + const { result } = renderHook(() => useShortcodeBodyPreview()); + + // ACT + await act(async () => { + await result.current.initiatePreview(contentWithSetIds); + }); + + // ASSERT + await waitFor(() => { + expect(result.current.previewContent).toEqual('Try [game=29895] and [game=28000]'); + }); + }); + + it('given content with mixed game shortcodes (with and without sets), handles both correctly', async () => { + // ARRANGE + const contentWithMixedShortcodes = 'Play [game=668] or [game=668?set=8659]'; + + const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [ + createGame({ id: 668, title: 'Sonic the Hedgehog' }), + createGame({ id: 29895, title: 'Sonic the Hedgehog [Subset - Perfect Bonus]' }), + ], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'Play [game=668] or [game=29895]', + }, + }); + + const { result } = renderHook(() => useShortcodeBodyPreview()); + + // ACT + await act(async () => { + await result.current.initiatePreview(contentWithMixedShortcodes); + }); + + // ASSERT + await waitFor(() => { + expect(postSpy).toHaveBeenCalledWith(['api.shortcode-body.preview'], { + body: contentWithMixedShortcodes, + }); + }); + + await waitFor(() => { + expect(result.current.previewContent).toEqual('Play [game=668] or [game=29895]'); + }); + }); + + it('given content with a game set shortcode but no backing game found, falls back to the original shortcode', async () => { + // ARRANGE + const contentWithInvalidSetId = 'Check out [game=668?set=99999]'; + + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'Check out [game=668?set=99999]', + }, + }); + + const { result } = renderHook(() => useShortcodeBodyPreview()); + + // ACT + await act(async () => { + await result.current.initiatePreview(contentWithInvalidSetId); + }); + + // ASSERT + await waitFor(() => { + expect(result.current.previewContent).toEqual('Check out [game=668?set=99999]'); + }); + }); + + it('given content with multiple game set shortcodes but only some have backing games, converts stuff correctly', async () => { + // ARRANGE + const contentWithMixedValidSets = 'Try [game=1?set=9534] and [game=2?set=8888]'; + + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [createGame({ id: 29895, title: 'Sonic Subset' })], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'Try [game=29895] and [game=2?set=8888]', + }, + }); + + const { result } = renderHook(() => useShortcodeBodyPreview()); + + // ACT + await act(async () => { + await result.current.initiatePreview(contentWithMixedValidSets); + }); + + // ASSERT + await waitFor(() => { + expect(result.current.previewContent).toEqual('Try [game=29895] and [game=2?set=8888]'); // !! + }); + }); + + it('given content with both regular games and set shortcodes but the backend returns no backing games for sets, keeps set shortcodes unchanged', async () => { + // ARRANGE + const contentWithMixed = 'Play [game=668] or [game=1?set=9534]'; + + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [ + createGame({ id: 668, title: 'Sonic the Hedgehog' }), + // ... no backing game for set ID 9534 ... + ], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'Play [game=668] or [game=1?set=9534]', + }, + }); + + const { result } = renderHook(() => useShortcodeBodyPreview()); + + // ACT + await act(async () => { + await result.current.initiatePreview(contentWithMixed); + }); + + // ASSERT + await waitFor(() => { + expect(result.current.previewContent).toEqual('Play [game=668] or [game=1?set=9534]'); + }); + }); }); diff --git a/resources/js/common/hooks/useShortcodeBodyPreview.ts b/resources/js/common/hooks/useShortcodeBodyPreview.ts index 0598b53160..6beada3bde 100644 --- a/resources/js/common/hooks/useShortcodeBodyPreview.ts +++ b/resources/js/common/hooks/useShortcodeBodyPreview.ts @@ -9,7 +9,6 @@ import { persistedTicketsAtom, persistedUsersAtom, } from '@/common/state/shortcode.atoms'; -import { extractDynamicEntitiesFromBody } from '@/common/utils/shortcodes/extractDynamicEntitiesFromBody'; import { preProcessShortcodesInBody } from '@/common/utils/shortcodes/preProcessShortcodesInBody'; import type { ShortcodeBodyPreviewMutationResponse } from '../models'; @@ -44,20 +43,13 @@ export function useShortcodeBodyPreview() { // Normalize any internal URLs to shortcode format. const normalizedBody = preProcessShortcodesInBody(body); - // Then, extract dynamic entities from the normalized content. - const dynamicEntities = extractDynamicEntitiesFromBody(normalizedBody); + // Send the body to the server for entity extraction and fetching. + // The server will handle normalizing, converting [game=X?set=Y] to backing game IDs, and extracting entities. + const response = await mutation.mutateAsync(normalizedBody); + mergeRetrievedEntities(response.data); - // Do we have any dynamic entities to fetch from the server? - // If not, we'll skip a round trip to the server to make the preview seem instantaneous. - const hasDynamicEntities = Object.values(dynamicEntities).some((arr) => arr.length > 0); - - // If there are no dynamic entities in the post content, skip the round trip to the server. - if (hasDynamicEntities) { - const response = await mutation.mutateAsync(dynamicEntities); - mergeRetrievedEntities(response.data); - } - - setPreviewContent(normalizedBody); + // Use the converted body from the server (eg: "[game=X?set=Y]"" has been converted to "[game=backingGameId]"). + setPreviewContent(response.data.convertedBody); }; /** diff --git a/resources/js/common/models/shortcode/dynamic-shortcode-entities.model.ts b/resources/js/common/models/shortcode/dynamic-shortcode-entities.model.ts deleted file mode 100644 index f692223e03..0000000000 --- a/resources/js/common/models/shortcode/dynamic-shortcode-entities.model.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface DynamicShortcodeEntities { - achievementIds: number[]; - gameIds: number[]; - hubIds: number[]; - eventIds: number[]; - ticketIds: number[]; - usernames: string[]; -} diff --git a/resources/js/common/models/shortcode/index.ts b/resources/js/common/models/shortcode/index.ts index babd5dc56a..724c01f612 100644 --- a/resources/js/common/models/shortcode/index.ts +++ b/resources/js/common/models/shortcode/index.ts @@ -1,4 +1,3 @@ -export * from './dynamic-shortcode-entities.model'; export * from './processed-video.model'; export * from './shortcode.model'; export * from './shortcode-preview-mutation-response.model'; diff --git a/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts b/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts index 746af989d4..3a10dc771e 100644 --- a/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts +++ b/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts @@ -1,8 +1,9 @@ export interface ShortcodeBodyPreviewMutationResponse { achievements: App.Platform.Data.Achievement[]; + convertedBody: string; + events: App.Platform.Data.Event[]; games: App.Platform.Data.Game[]; hubs: App.Platform.Data.GameSet[]; - events: App.Platform.Data.Event[]; tickets: App.Platform.Data.Ticket[]; users: App.Data.User[]; } diff --git a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.test.ts b/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.test.ts deleted file mode 100644 index e6d2e49a4f..0000000000 --- a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { extractDynamicEntitiesFromBody } from './extractDynamicEntitiesFromBody'; - -describe('Util: extractDynamicEntitiesFromBody', () => { - it('is defined', () => { - // ASSERT - expect(extractDynamicEntitiesFromBody).toBeDefined(); - }); - - it('given the input contains user shortcodes, extracts and dedupes all usernames', () => { - // ARRANGE - const input = 'Hello [user=Jamiras] and [user=Scott] and [user=Jamiras] again'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.usernames).toEqual(['Jamiras', 'Scott']); - }); - - it('given the input contains ticket shortcodes, extracts and dedupes all ticket IDs', () => { - // ARRANGE - const input = 'Check tickets [ticket=123] and [ticket=456] and [ticket=123].'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.ticketIds).toEqual([123, 456]); - }); - - it('given the input contains achievement shortcodes, extracts all achievement IDs', () => { - // ARRANGE - const input = 'I earned [ach=9] and [ach=14402]!'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.achievementIds).toEqual([9, 14402]); - }); - - it('given the input contains game shortcodes, extracts and dedupes all game IDs', () => { - // ARRANGE - const input = 'I like to play [game=1] and [game=14402] and [game=1].'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.gameIds).toEqual([1, 14402]); - }); - - it('given the input contains hub shortcodes, extracts and dedupes all hub IDs', () => { - // ARRANGE - const input = 'I like to visit [hub=1] and [hub=2] and [hub=1].'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.hubIds).toEqual([1, 2]); - }); - - it('given the input contains event shortcodes, extracts and dedupes all event IDs', () => { - // ARRANGE - const input = 'I play in [event=1] and [event=2] and [event=1].'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.eventIds).toEqual([1, 2]); - }); - - it('given the input contains invalid numeric IDs, ignores them', () => { - // ARRANGE - const input = '[ticket=abc] [ach=def] [game=xyz]'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.ticketIds).toEqual([]); - expect(result.achievementIds).toEqual([]); - expect(result.gameIds).toEqual([]); - }); - - it('given the input contains multiple types of shortcodes, extracts all entities correctly', () => { - // ARRANGE - const input = '[user=xelnia] completed [ach=9] in [game=1] and created [ticket=12345].'; - - // ACT - const result = extractDynamicEntitiesFromBody(input); - - // ASSERT - expect(result.usernames).toEqual(['xelnia']); - expect(result.achievementIds).toEqual([9]); - expect(result.gameIds).toEqual([1]); - expect(result.ticketIds).toEqual([12345]); - }); -}); diff --git a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.ts b/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.ts deleted file mode 100644 index 6712794673..0000000000 --- a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { DynamicShortcodeEntities } from '@/common/models'; - -const shortcodePatterns = { - achievement: /\[ach=([^\]]+)\]/g, - game: /\[game=([^\]]+)\]/g, - hub: /\[hub=([^\]]+)\]/g, - event: /\[event=([^\]]+)\]/g, - ticket: /\[ticket=([^\]]+)\]/g, - user: /\[user=([^\]]+)\]/g, -}; - -export function extractDynamicEntitiesFromBody(input: string): DynamicShortcodeEntities { - const entities: DynamicShortcodeEntities = { - achievementIds: [], - gameIds: [], - hubIds: [], - eventIds: [], - ticketIds: [], - usernames: [], - }; - - // Extract achievement IDs. - for (const match of input.matchAll(shortcodePatterns.achievement)) { - const id = parseInt(match[1], 10); - if (!isNaN(id)) entities.achievementIds.push(id); - } - - // Extract game IDs. - for (const match of input.matchAll(shortcodePatterns.game)) { - const id = parseInt(match[1], 10); - if (!isNaN(id)) entities.gameIds.push(id); - } - - // Extract hub IDs. - for (const match of input.matchAll(shortcodePatterns.hub)) { - const id = parseInt(match[1], 10); - if (!isNaN(id)) entities.hubIds.push(id); - } - - // Extract event IDs. - for (const match of input.matchAll(shortcodePatterns.event)) { - const id = parseInt(match[1], 10); - if (!isNaN(id)) entities.eventIds.push(id); - } - - // Extract ticket IDs. - for (const match of input.matchAll(shortcodePatterns.ticket)) { - const id = parseInt(match[1], 10); - if (!isNaN(id)) entities.ticketIds.push(id); - } - - // Extract usernames. - for (const match of input.matchAll(shortcodePatterns.user)) { - entities.usernames.push(match[1]); - } - - return { - achievementIds: [...new Set(entities.achievementIds)], - gameIds: [...new Set(entities.gameIds)], - hubIds: [...new Set(entities.hubIds)], - eventIds: [...new Set(entities.eventIds)], - ticketIds: [...new Set(entities.ticketIds)], - usernames: [...new Set(entities.usernames)], - }; -} diff --git a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts index 1762b0555f..3334524b30 100644 --- a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts +++ b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts @@ -205,4 +205,139 @@ describe('Util: preProcessShortcodesInBody', () => { // ASSERT expect(result).toEqual('Check out [user=Scott]'); }); + + it('converts game URL with ?set= query parameter to shortcode', () => { + // ARRANGE + const input = 'Check out https://retroachievements.org/game/668?set=8659'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Check out [game=668?set=8659]'); + }); + + it('converts game URL with ?set= parameter and slug to shortcode', () => { + // ARRANGE + const input = 'Try https://retroachievements.org/game/668-pokemon-emerald-version?set=8659'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Try [game=668?set=8659]'); + }); + + it('converts localhost game URL with ?set= parameter to shortcode', () => { + // ARRANGE + const input = 'Dev: http://localhost:64000/game/123?set=456'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Dev: [game=123?set=456]'); + }); + + it('handles mixed game URLs with and without ?set= parameter', () => { + // ARRANGE + const input = + 'Play https://retroachievements.org/game/668 or try https://retroachievements.org/game/668?set=8659'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Play [game=668] or try [game=668?set=8659]'); + }); + + it('prioritizes game URLs with ?set= parameter over regular game URLs', () => { + // ARRANGE + const input = 'https://retroachievements.org/game/1?set=9534'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + // ... should become [game=1?set=9534], not [game=1] ... + expect(result).toEqual('[game=1?set=9534]'); + }); + + it('converts /game2/ URLs to shortcode', () => { + // ARRANGE + const input = 'Check out https://retroachievements.org/game2/1234'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Check out [game=1234]'); + }); + + it('converts /game2/ URLs with slugs to shortcode', () => { + // ARRANGE + const input = 'Check out https://retroachievements.org/game2/1234-super-mario-64'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Check out [game=1234]'); + }); + + it('converts /game2/ URLs with ?set= query params to shortcode', () => { + // ARRANGE + const input = 'Check out https://retroachievements.org/game2/668?set=8659'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Check out [game=668?set=8659]'); + }); + + it('converts /game2/ URLs with ?set= parameters and slugs to shortcode', () => { + // ARRANGE + const input = 'Try https://retroachievements.org/game2/668-pokemon-emerald-version?set=8659'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Try [game=668?set=8659]'); + }); + + it('converts localhost /game2/ URLs with ?set= parameters to shortcode', () => { + // ARRANGE + const input = 'Dev: http://localhost:64000/game2/123?set=456'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Dev: [game=123?set=456]'); + }); + + it('handles mixed /game/ and /game2/ URLs', () => { + // ARRANGE + const input = + 'Play https://retroachievements.org/game/668 or try https://retroachievements.org/game2/668?set=8659'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('Play [game=668] or try [game=668?set=8659]'); + }); + + it('converts /game2/ BBCode url tags to shortcode', () => { + // ARRANGE + const input = '[url=https://retroachievements.org/game2/1234]Cool Game[/url]'; + + // ACT + const result = preProcessShortcodesInBody(input); + + // ASSERT + expect(result).toEqual('[game=1234]'); + }); }); diff --git a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts index 103c5515a3..f74a50b4d3 100644 --- a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts +++ b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts @@ -7,31 +7,55 @@ const shortcodeTypes = [ { type: 'user', shortcode: 'user' }, ] as const; -const createPatterns = (type: string) => [ - // HTML anchor tags. - new RegExp( - `]*retroachievements\\.org/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^/>]*\\][^]*`, - 'gi', - ), - - // BBCode url tags. - new RegExp( - `\\[url[^\\]]*retroachievements\\.org/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^\\]]*\\][^\\[]*\\[/url\\]`, - 'gi', - ), - - // Direct production URLs. - new RegExp( - `https?://(?:[\\w-]+\\.)?retroachievements\\.org/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`, - 'gi', - ), - - // Local development URLs. - new RegExp( - `https?://localhost(?::\\d{1,5})?/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`, - 'gi', - ), -]; +const createPatterns = (type: string) => { + // For games, match both /game/ and /game2/ URLs. + const pathPattern = type === 'game' ? 'game2?' : type; + + const patterns = [ + // HTML anchor tags. + new RegExp( + `]*retroachievements\\.org/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^/>]*\\][^]*`, + 'gi', + ), + + // BBCode url tags. + new RegExp( + `\\[url[^\\]]*retroachievements\\.org/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^\\]]*\\][^\\[]*\\[/url\\]`, + 'gi', + ), + + // Direct production URLs without query params. + new RegExp( + `https?://(?:[\\w-]+\\.)?retroachievements\\.org/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`, + 'gi', + ), + + // Local development URLs without query params. + new RegExp( + `https?://localhost(?::\\d{1,5})?/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`, + 'gi', + ), + ]; + + // For games specifically, also handle URLs with ?set= query parameter. + if (type === 'game') { + patterns.unshift( + // Production URLs with a ?set= parameter. + new RegExp( + `https?://(?:[\\w-]+\\.)?retroachievements\\.org/game2?/(\\w{1,20})(?:-[^\\s"'<>]*)?(?:/)?\\?set=(\\d+)`, + 'gi', + ), + + // Local development URLs with a ?set= parameter. + new RegExp( + `https?://localhost(?::\\d{1,5})?/game2?/(\\w{1,20})(?:-[^\\s"'<>]*)?(?:/)?\\?set=(\\d+)`, + 'gi', + ), + ); + } + + return patterns; +}; export function preProcessShortcodesInBody(body: string): string { // First, normalize any escaped newlines back to actual newlines. @@ -42,8 +66,15 @@ export function preProcessShortcodesInBody(body: string): string { for (const { type, shortcode } of shortcodeTypes) { const patterns = createPatterns(type); - for (const regex of patterns) { - result = result.replaceAll(regex, `[${shortcode}=$1]`); + for (let i = 0; i < patterns.length; i++) { + const regex = patterns[i]; + + // Special handling for game URLs with a ?set= parameter (first two patterns for games). + if (type === 'game' && i < 2) { + result = result.replaceAll(regex, `[${shortcode}=$1?set=$2]`); + } else { + result = result.replaceAll(regex, `[${shortcode}=$1]`); + } } } diff --git a/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx b/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx index 4e7eca3f2c..5322bfea35 100644 --- a/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx +++ b/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx @@ -1,4 +1,5 @@ import userEvent from '@testing-library/user-event'; +import axios from 'axios'; import { createAuthenticatedUser } from '@/common/models'; import { render, screen, waitFor } from '@/test'; @@ -27,6 +28,18 @@ describe('Component: MessagesCreateRoot', () => { it('given the user previews their message, shows the preview content', async () => { // ARRANGE + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'hello world', + }, + }); + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, diff --git a/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx b/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx index 30767e5068..6eb5945076 100644 --- a/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx +++ b/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx @@ -1,5 +1,6 @@ import { router } from '@inertiajs/react'; import userEvent from '@testing-library/user-event'; +import axios from 'axios'; import { route } from 'ziggy-js'; import { createAuthenticatedUser } from '@/common/models'; @@ -106,6 +107,18 @@ describe('Component: MessagesShowRoot', () => { it('given the user previews a message, shows the preview content', async () => { // ARRANGE + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + achievements: [], + games: [], + hubs: [], + events: [], + tickets: [], + users: [], + convertedBody: 'hello world', + }, + }); + const messageThread = createMessageThread(); const paginatedMessages = createPaginatedData([createMessage()]); @@ -126,7 +139,9 @@ describe('Component: MessagesShowRoot', () => { await userEvent.click(previewButton); // ASSERT - expect(screen.getAllByText(/hello world/i).length).toEqual(2); // textarea and preview div + await waitFor(() => { + expect(screen.getAllByText(/hello world/i).length).toEqual(2); // textarea and preview div + }); }); it('given the user paginates, changes the current route correctly', async () => { diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 18ebb5fe6e..f5d7d103f9 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -162,6 +162,7 @@ declare namespace App.Community.Data { games: Array; hubs: Array; events: Array; + convertedBody: string; }; export type Subscription = { id: number; diff --git a/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php b/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php index 80862923e4..5003e8cfb9 100644 --- a/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php +++ b/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php @@ -17,12 +17,7 @@ public function testItValidatesRequiredFields(): void $this->withoutMiddleware(); $payload = [ - // !! missing required "usernames" array - 'ticketIds' => [], - 'achievementIds' => [], - 'gameIds' => [], - 'hubIds' => [], - 'eventIds' => [], + // ... missing required "body" field ... ]; // Act @@ -30,35 +25,6 @@ public function testItValidatesRequiredFields(): void // Assert $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['usernames']); - } - - public function testItReturnsEmptyCollectionsWhenNoIdsProvided(): void - { - // Arrange - $this->withoutMiddleware(); - - $payload = [ - 'usernames' => [], - 'ticketIds' => [], - 'achievementIds' => [], - 'gameIds' => [], - 'hubIds' => [], - 'eventIds' => [], - ]; - - // Act - $response = $this->postJson(route('api.shortcode-body.preview'), $payload); - - // Assert - $response->assertOk(); - $response->assertExactJson([ - 'users' => [], - 'tickets' => [], - 'achievements' => [], - 'games' => [], - 'hubs' => [], - 'events' => [], - ]); + $response->assertJsonValidationErrors(['body']); } } diff --git a/tests/Feature/Community/ShortcodeTest.php b/tests/Feature/Community/ShortcodeTest.php index 29edbc660c..c8cbd03719 100644 --- a/tests/Feature/Community/ShortcodeTest.php +++ b/tests/Feature/Community/ShortcodeTest.php @@ -232,6 +232,42 @@ public function testNormalizeGameShortcodes(): void ); } + public function testNormalizeGame2Shortcodes(): void + { + $rawString = 'https://retroachievements.org/game2/1'; + + $normalized = normalize_shortcodes($rawString); + + $this->assertEquals( + '[game=1]', + $normalized + ); + } + + public function testNormalizeGameShortcodesWithSetParam(): void + { + $rawString = 'https://retroachievements.org/game/668?set=8659'; + + $normalized = normalize_shortcodes($rawString); + + $this->assertEquals( + '[game=668?set=8659]', + $normalized + ); + } + + public function testNormalizeGame2ShortcodesWithSetParam(): void + { + $rawString = 'https://retroachievements.org/game2/668?set=8659'; + + $normalized = normalize_shortcodes($rawString); + + $this->assertEquals( + '[game=668?set=8659]', + $normalized + ); + } + public function testNormalizeHubShortcodes(): void { $rawString = 'https://retroachievements.org/hub/1';