From 011cbfc950afc273c54b784f1d436728a5d680b6 Mon Sep 17 00:00:00 2001 From: Oliver Tacke Date: Tue, 20 Oct 2026 11:06:42 +0200 Subject: [PATCH] Feat/support migrating H5P.ThreeImage to H5P.EscapeRoom --- .../Admin/AdminContentMigrateController.php | 192 +++++++++++++++--- .../views/admin/migrate/index.blade.php | 13 +- .../apis/contentauthor/routes/admin.php | 2 +- 3 files changed, 178 insertions(+), 29 deletions(-) diff --git a/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminContentMigrateController.php b/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminContentMigrateController.php index 878776a18..8d94e3d05 100644 --- a/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminContentMigrateController.php +++ b/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminContentMigrateController.php @@ -19,6 +19,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use JsonException; use Ramsey\Uuid\Uuid; @@ -42,21 +43,27 @@ public function index(Request $request): View $contents = collect(); $count = 0; - $fromLibrary = H5PLibrary::where('name', 'H5P.NDLAThreeImage') + $fromLibraries = H5PLibrary::whereIn('name', ['H5P.NDLAThreeImage', 'H5P.ThreeImage']) ->where('major_version', 0) ->where('minor_version', 5) - ->first(); + ->get() + ->keyBy('name'); $toLibrary = H5PLibrary::where('name', 'H5P.EscapeRoom') ->where('major_version', 0) ->where('minor_version', 7) ->first(); - if ($fromLibrary !== null && $toLibrary !== null) { + if ($fromLibraries->isNotEmpty() && $toLibrary !== null) { if ($request->method() === 'POST' && $request->has('content')) { - $migrated = $this->migrate($fromLibrary, $toLibrary, $request->input('content')); + $migrated = $this->migrate($fromLibraries, $toLibrary, $request->input('content')); } - $leaves = $this->getLeaves(); + $leaves = $fromLibraries + ->flatMap(function (H5PLibrary $fromLibrary) { + return collect($this->getLeaves($fromLibrary->name)) + ->map(fn (array $leaf) => $leaf + ['source_library' => $fromLibrary->name]); + }) + ->values(); $routePrefix = route('h5p.ltishow', '') . '/'; // Extract H5P IDs from the leaf launch URLs @@ -72,7 +79,7 @@ public function index(Request $request): View ->forget(''); // Single query to fetch all matching H5P content - $h5pContents = H5PContent::where('library_id', $fromLibrary->id) + $h5pContents = H5PContent::whereIn('library_id', $fromLibraries->pluck('id')) ->whereIn('id', $leafsByH5pId->keys()) ->get() ->keyBy('id'); @@ -84,6 +91,7 @@ public function index(Request $request): View return (object)[ 'h5p_id' => $h5pContent->id, 'title' => $leaf['title'] ?: $h5pContent->title, + 'source_library' => $leaf['source_library'], 'hub_content_id' => $leaf['content_id'], 'update_url' => $leaf['update_url'], ]; @@ -94,7 +102,7 @@ public function index(Request $request): View } return view('admin.migrate.index', [ - 'fromLibrary' => $fromLibrary, + 'fromLibraries' => $fromLibraries, 'toLibrary' => $toLibrary, 'migrated' => $migrated ?? [], 'paginator' => (new LengthAwarePaginator($contents, $count, $pageSize)) @@ -103,12 +111,14 @@ public function index(Request $request): View } /** + * @param Collection $fromLibraries * @param array $items */ - private function migrate(H5pLibrary $fromLibrary, H5pLibrary $toLibrary, array $items): array + private function migrate(Collection $fromLibraries, H5PLibrary $toLibrary, array $items): array { $migrated = []; $runId = Uuid::uuid4()->toString(); + $librariesById = $fromLibraries->keyBy('id'); foreach ($items as $item) { $h5pId = $item['h5p_id'] ?? null; @@ -118,8 +128,11 @@ private function migrate(H5pLibrary $fromLibrary, H5pLibrary $toLibrary, array $ continue; } - $sourceH5p = H5PContent::where('id', $h5pId)->where('library_id', $fromLibrary->id)->first(); + $sourceH5p = H5PContent::where('id', $h5pId) + ->whereIn('library_id', $librariesById->keys()) + ->first(); if ($sourceH5p !== null) { + $fromLibrary = $librariesById[$sourceH5p->library_id]; $logData = [ 'runId' => $runId, 'fromLibrary' => [ @@ -156,7 +169,7 @@ private function migrate(H5pLibrary $fromLibrary, H5pLibrary $toLibrary, array $ } finally { $migrated[$sourceH5p->id] = $result; AuditLog::log( - 'Migrate content from H5P.NDLAThreeImage to H5P.EscapeRoom', + 'Migrate content from ' . $fromLibrary->name . ' to ' . $toLibrary->name, json_encode($logData), ); } @@ -175,31 +188,164 @@ private function alterParameters(string $parameters, string $libraryName): strin if ($libraryName == 'H5P.ThreeImage') { $content['threeImage']['wasConvertedFromVirtualTour'] = true; } - for ($i = 0; $i < count($content["threeImage"]["scenes"] ?? []); $i++) { - $content["threeImage"]["scenes"][$i]["enableZoom"] = true; - /* - * From code at https://github.com/NDLANO/h5p-vt2er/blob/c11a34b9cdaa6842c1430a79912e78531ca21bcb/h5p-vt2er/app/H5PVT2ER.php#L299 - * May or may not be interested in adding this as well - for ($j = 0; $j < count($content["threeImage"]["scenes"][$i]["interactions"] ?? []); $j++) { - $content["threeImage"]["scenes"][$i]["interactions"][$j]["iconTypeTextBox"] = "text-icon"; - $content["threeImage"]["scenes"][$i]["interactions"][$j]["showAsHotspot"] = false; - $content["threeImage"]["scenes"][$i]["interactions"][$j]["showAsOpenSceneContent"] = false; - } - */ + if (isset($content["threeImage"]["scenes"])) { + $scenes = $content["threeImage"]["scenes"]; + $scenes = $this->migrateScenes($scenes, $libraryName); + $content["threeImage"]["scenes"] = $scenes; + } + foreach ($this->l10nDefaults($libraryName) as $key => $translation) { + $content["l10n"][$key] = $content["l10n"][$key] ?? $translation; } return json_encode($content, flags: JSON_THROW_ON_ERROR); } + /** + * Enable zoom for scenes, and for H5P.ThreeImage (Virtual Tour) content adapt the interactions + * to the layout Escape Room uses for converted content. + * + * @param array $scenes + * + * @return array + */ + private function migrateScenes(array $scenes, string $libraryName): array + { + for ($i = 0; $i < count($scenes); $i++) { + $scenes[$i]["enableZoom"] = true; + + if ($libraryName == 'H5P.ThreeImage') { + for ($j = 0; $j < count($scenes[$i]["interactions"] ?? []); $j++) { + $scenes[$i]["interactions"][$j]["iconTypeTextBox"] = "text-icon"; + $scenes[$i]["interactions"][$j]["showAsHotspot"] = false; + $scenes[$i]["interactions"][$j]["showAsOpenSceneContent"] = false; + } + } + } + + if ($libraryName == 'H5P.NDLAThreeImage') { + $scenes = $this->fixHotspotInteractionPositions($scenes); + } + + return $scenes; + } + + /** + * Compensate for `transform: translate(-50%, -50%)` set on "nav buttons" shown as hotspot other than + * GoToScene in static scenes and panorama scenes. + * + * @param array $scenes + * + * @return array + */ + private function fixHotspotInteractionPositions(array $scenes): array + { + foreach ($scenes as $sceneIndex => $scene) { + if (($scene["sceneType"] ?? null) === "360" || !isset($scene["interactions"])) { + continue; + } + + foreach ($scene["interactions"] as $interactionIndex => $interaction) { + if ( + ($interaction["showAsHotspot"] ?? null) !== true || + !isset($interaction["action"]["library"]) || + $interaction["action"]["library"] === "H5P.GoToScene 0.1" + ) { + continue; + } + + $positions = isset($interaction["interactionpos"]) + ? explode(",", $interaction["interactionpos"]) + : []; + $sizes = isset($interaction["hotspotSettings"]["hotSpotSizeValues"]) + ? explode(",", $interaction["hotspotSettings"]["hotSpotSizeValues"]) + : []; + + if (count($positions) < 2 || count($sizes) < 2) { + continue; + } + // `interactionpos` is something like "1.234%,5.678%" + $left = (float)trim($positions[0]); + $top = (float)trim($positions[1]); + + // `hotSpotSizeValues` is something like "1.234,5.678" + $width = (float)$sizes[0]; + $height = (float)$sizes[1]; + + $scenes[$sceneIndex]["interactions"][$interactionIndex]["interactionpos"] = + ($left + $width / 2) . "%," . ($top + $height / 2) . "%"; + } + } + + return $scenes; + } + + /** + * Default translations for Escape Room UI strings that Virtual Tour content does not have yet. + * + * @return array + */ + private function l10nDefaults(string $libraryName): array + { + $defaults = [ + "buttonZoomIn" => "Zoom in", + "buttonZoomOut" => "Zoom out", + "zoomToolbar" => "Zoom toolbar", + "zoomAriaLabel" => "num% zoomed in", + ]; + + if ($libraryName == 'H5P.ThreeImage') { + return [ + "title" => "Title", + "playAudioTrack" => "Play audio track", + "pauseAudioTrack" => "Pause audio track", + "sceneDescription" => "Scene description", + "resetCamera" => "Reset camera", + "submitDialog" => "Submit dialog", + "closeDialog" => "Close dialog", + "expandButtonAriaLabel" => "Expand the visual label", + "backgroundLoading" => "Loading background image ...", + "noContent" => "No content", + "goToScene" => "Go to scene", + "edit" => "Edit", + "delete" => "Delete", + "score" => "Score", + "assignment" => "Assignment", + "total" => "Total", + "scoreSummary" => "Show score summary", + "scene" => "Scene", + "untitled" => "Untitled", + "userIsAtStartScene" => "You are at the start scene", + "unlocked" => "Unlocked", + "locked" => "Locked", + "searchRoomForCode" => "Search the room until you find the code", + "wrongCode" => "The code was wrong, try again.", + "contentUnlocked" => "The content has been unlocked!", + "code" => "Code", + "lockedStateAction" => "Unlock", + "hotspotDragHorizAlt" => "Drag horizontally to scale", + "hotspotDragVertiAlt" => "Drag vertically to scale", + "hint" => "Hint", + "lockedContent" => "Locked content", + "back" => "Back", + "buttonFullscreenEnter" => "Enter fullscreen mode", + "buttonFullscreenExit" => "Exit fullscreen mode", + "mainToolbar" => "Main toolbar", + "noValidSceneSet" => "No valid scenes have been set.", + ] + $defaults; + } + + return $defaults; + } + /** * @return array * @throws GuzzleException|JsonException|RuntimeException */ - private function getLeaves(): array + private function getLeaves(string $machineName): array { $decoded = $this->hubClient->post( '/content-versions/leaves', - ['tag' => 'h5p:h5p.ndlathreeimage'], + ['tag' => 'h5p:h5p.' . strtolower($machineName)], ); return $decoded['data'] ?? []; diff --git a/sourcecode/apis/contentauthor/resources/views/admin/migrate/index.blade.php b/sourcecode/apis/contentauthor/resources/views/admin/migrate/index.blade.php index 57427fa57..c39c663ae 100644 --- a/sourcecode/apis/contentauthor/resources/views/admin/migrate/index.blade.php +++ b/sourcecode/apis/contentauthor/resources/views/admin/migrate/index.blade.php @@ -7,15 +7,17 @@

- Migrate the contents of library H5P.NDLAThreeImage 0.5.x to H5P.EscapeRoom 0.7.x + Migrate the contents of libraries H5P.NDLAThreeImage 0.5.x and H5P.ThreeImage 0.5.x to H5P.EscapeRoom 0.7.x

- H5P.NDLAThreeImage 0.5.x: @if($fromLibrary) {{ $fromLibrary->getLibraryString(true) }} @else Not found @endif -

+ @foreach(['H5P.NDLAThreeImage', 'H5P.ThreeImage'] as $libraryName) + {{ $libraryName }} 0.5.x: @if($library = $fromLibraries->get($libraryName)) {{ $library->getLibraryString(true) }} @else Not found @endif +

+ @endforeach H5P.EscapeRoom 0.7.x: @if($toLibrary) {{ $toLibrary->getLibraryString(true) }} @else Not found @endif

- @if ($toLibrary && $fromLibrary) + @if ($toLibrary && $fromLibraries->isNotEmpty()) @if (count($migrated) > 0)

Migrated content

@@ -56,6 +58,7 @@ Migrate Content id Title + Library @@ -67,13 +70,13 @@ {{ $content->h5p_id }} {{ $content->title }} + {{ $content->source_library }} @endforeach @csrf -
diff --git a/sourcecode/apis/contentauthor/routes/admin.php b/sourcecode/apis/contentauthor/routes/admin.php index a168d7b75..f1b93019c 100644 --- a/sourcecode/apis/contentauthor/routes/admin.php +++ b/sourcecode/apis/contentauthor/routes/admin.php @@ -72,7 +72,7 @@ function () { Route::match(['GET', 'POST'], 'ajax', [AdminController::class, 'ajaxLoading'])->name('admin.ajax'); - // Migrate content from H5P.NDLAThreeImage to H5P.EscapeRoom + // Migrate content from H5P.NDLAThreeImage and H5P.ThreeImage to H5P.EscapeRoom Route::match(['GET', 'POST'], '/migrate/library-content', [AdminContentMigrateController::class, 'index']) ->name('admin.migrate.library-content');