diff --git a/sourcecode/apis/contentauthor/app/Article.php b/sourcecode/apis/contentauthor/app/Article.php
index 0d5b9549e6..fb9cee7806 100644
--- a/sourcecode/apis/contentauthor/app/Article.php
+++ b/sourcecode/apis/contentauthor/app/Article.php
@@ -301,11 +301,4 @@ private static function rewriteUploadUrls(string $content): string
libxml_use_internal_errors($previous);
}
}
-
- protected function getTags(): array
- {
- return [
- 'h5p:' . $this->getMachineName(),
- ];
- }
}
diff --git a/sourcecode/apis/contentauthor/app/Content.php b/sourcecode/apis/contentauthor/app/Content.php
index 47076974ab..d7d155c415 100644
--- a/sourcecode/apis/contentauthor/app/Content.php
+++ b/sourcecode/apis/contentauthor/app/Content.php
@@ -88,6 +88,11 @@ abstract public function getUrl(): string;
abstract public function getMachineName(): string;
+ public function getMachineDisplayName(): string
+ {
+ return $this->getMachineName();
+ }
+
public function getTitleCleanAttribute(): string|null
{
if ($this->title === null) {
@@ -368,6 +373,7 @@ public function toLtiContent(
url: $this->getUrl(),
title: $this->title_clean,
machineName: $this->getMachineName(),
+ machineDisplayName: $this->getMachineDisplayName(),
hasScore: ($this->getMaxScore() ?? 0) > 0,
editUrl: $this->getEditUrl(),
titleHtml: $this->title,
diff --git a/sourcecode/apis/contentauthor/app/Game.php b/sourcecode/apis/contentauthor/app/Game.php
index 5cdd6159f8..190cbc5bb3 100644
--- a/sourcecode/apis/contentauthor/app/Game.php
+++ b/sourcecode/apis/contentauthor/app/Game.php
@@ -140,13 +140,6 @@ public function getMachineName(): string
return 'Game';
}
- protected function getTags(): array
- {
- return [
- 'h5p:' . $this->getMachineName(),
- ];
- }
-
public function getMaxScore(): int|null
{
try {
diff --git a/sourcecode/apis/contentauthor/app/H5PContent.php b/sourcecode/apis/contentauthor/app/H5PContent.php
index f93ca9e3ce..944d37071d 100644
--- a/sourcecode/apis/contentauthor/app/H5PContent.php
+++ b/sourcecode/apis/contentauthor/app/H5PContent.php
@@ -338,6 +338,11 @@ public function getMachineName(): string
return $this->library()->firstOrFail()->name;
}
+ public function getMachineDisplayName(): string
+ {
+ return $this->library()->first()->title ?? $this->getMachineName();
+ }
+
public function getCopyrightCacheKey(): string
{
return 'h5p-copyright-' . $this->id;
@@ -352,11 +357,4 @@ protected function getIconUrl(): string
{
return $this->library()->firstOrFail()->getIconUrl();
}
-
- protected function getTags(): array
- {
- return [
- 'h5p:' . $this->getMachineName(),
- ];
- }
}
diff --git a/sourcecode/apis/contentauthor/app/Http/Controllers/API/H5PLibraryController.php b/sourcecode/apis/contentauthor/app/Http/Controllers/API/H5PLibraryController.php
index bdb48d5619..7a552d0f58 100644
--- a/sourcecode/apis/contentauthor/app/Http/Controllers/API/H5PLibraryController.php
+++ b/sourcecode/apis/contentauthor/app/Http/Controllers/API/H5PLibraryController.php
@@ -5,6 +5,10 @@
use App\H5PLibrary;
use App\Http\Controllers\Controller;
use H5PCore;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class H5PLibraryController extends Controller
@@ -29,4 +33,31 @@ public function getLibraryById($id)
return response($h5pLibrary);
}
+
+ public function getLibraryTitleByMachineName(Request $request): Response|JsonResponse
+ {
+ $request->validate([
+ "machineNames" => "required:array",
+ "machineNames.*" => "string",
+ ]);
+ $machineNames = $request->get('machineNames');
+ // Get the titles, we get 'title' for all versions, but the latest is first for each 'name' (machine-name)
+ $info = H5PLibrary::select(DB::raw('LOWER(name) as name'), 'title', 'major_version', 'minor_version')
+ ->whereIn('name', $machineNames)
+ ->orderBy('name', 'ASC')
+ ->orderBy('major_version', 'DESC')
+ ->orderBy('minor_version', 'DESC')
+ ->get()
+ ->toArray();
+
+ $result = array_reduce($info, function ($result, $item) {
+ $key = $item['name'];
+ if (!isset($result[$key])) {
+ $result[$key] = $item['title'];
+ }
+ return $result;
+ });
+
+ return new JsonResponse($result, 200);
+ }
}
diff --git a/sourcecode/apis/contentauthor/app/Http/Controllers/ReturnToCoreController.php b/sourcecode/apis/contentauthor/app/Http/Controllers/ReturnToCoreController.php
index 348d077c00..73264f91dd 100644
--- a/sourcecode/apis/contentauthor/app/Http/Controllers/ReturnToCoreController.php
+++ b/sourcecode/apis/contentauthor/app/Http/Controllers/ReturnToCoreController.php
@@ -54,6 +54,8 @@ public function __invoke(Request $request): Response
->withPublished($content->published)
->withShared($content->shared)
->withTags($content->tags)
+ ->withContentType($content->machineName)
+ ->withContentTypeName($content->machineDisplayName)
;
$returnRequest = new Oauth1Request('POST', $ltiRequest->getReturnUrl(), [
diff --git a/sourcecode/apis/contentauthor/app/Http/Kernel.php b/sourcecode/apis/contentauthor/app/Http/Kernel.php
index cf2ad045e7..122913827c 100644
--- a/sourcecode/apis/contentauthor/app/Http/Kernel.php
+++ b/sourcecode/apis/contentauthor/app/Http/Kernel.php
@@ -2,6 +2,7 @@
namespace App\Http;
+use App\Http\Middleware\AuthPsk;
use App\Http\Middleware\RequestId;
use App\Http\Middleware\AdapterMode;
use App\Http\Middleware\APIAuth;
@@ -60,6 +61,7 @@ class Kernel extends HttpKernel
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
+ 'auth.psk' => AuthPsk::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
diff --git a/sourcecode/apis/contentauthor/app/Http/Middleware/AuthPsk.php b/sourcecode/apis/contentauthor/app/Http/Middleware/AuthPsk.php
new file mode 100644
index 0000000000..22f005b6c9
--- /dev/null
+++ b/sourcecode/apis/contentauthor/app/Http/Middleware/AuthPsk.php
@@ -0,0 +1,25 @@
+header($headerField);
+ $preKey = env($pskEnvName);
+
+ if ($reqKey && $preKey && $reqKey === $preKey) {
+ return $next($request);
+ }
+
+ return response("Unauthorized", Response::HTTP_UNAUTHORIZED);
+ }
+}
diff --git a/sourcecode/apis/contentauthor/app/Libraries/DataObjects/LtiContent.php b/sourcecode/apis/contentauthor/app/Libraries/DataObjects/LtiContent.php
index 827b232a50..9a9e4801a1 100644
--- a/sourcecode/apis/contentauthor/app/Libraries/DataObjects/LtiContent.php
+++ b/sourcecode/apis/contentauthor/app/Libraries/DataObjects/LtiContent.php
@@ -17,6 +17,7 @@ public function __construct(
public string $url,
public string $title,
public string $machineName,
+ public string $machineDisplayName,
public bool $hasScore,
public string|null $titleHtml = null,
public string|null $editUrl = null,
diff --git a/sourcecode/apis/contentauthor/app/Link.php b/sourcecode/apis/contentauthor/app/Link.php
index 4024fdd83f..a02faf989c 100644
--- a/sourcecode/apis/contentauthor/app/Link.php
+++ b/sourcecode/apis/contentauthor/app/Link.php
@@ -113,11 +113,4 @@ public function getMachineName(): string
{
return 'Link';
}
-
- protected function getTags(): array
- {
- return [
- 'h5p:' . $this->getMachineName(),
- ];
- }
}
diff --git a/sourcecode/apis/contentauthor/app/Providers/RouteServiceProvider.php b/sourcecode/apis/contentauthor/app/Providers/RouteServiceProvider.php
index c281c7bba8..00a6d2a1e4 100644
--- a/sourcecode/apis/contentauthor/app/Providers/RouteServiceProvider.php
+++ b/sourcecode/apis/contentauthor/app/Providers/RouteServiceProvider.php
@@ -26,6 +26,7 @@ public function boot()
*/
public function map()
{
+ $this->mapRestRoutes();
$this->mapApiRoutes();
$this->mapWebRoutes();
$this->mapAdminRoutes();
@@ -74,4 +75,14 @@ protected function mapAdminRoutes()
require base_path('routes/admin.php');
});
}
+
+ protected function mapRestRoutes()
+ {
+ Route::group([
+ 'middleware' => 'api',
+ 'namespace' => $this->namespace,
+ ], function ($router) {
+ require base_path('routes/rest.php');
+ });
+ }
}
diff --git a/sourcecode/apis/contentauthor/app/QuestionSet.php b/sourcecode/apis/contentauthor/app/QuestionSet.php
index 9efef44d92..0d146692d1 100644
--- a/sourcecode/apis/contentauthor/app/QuestionSet.php
+++ b/sourcecode/apis/contentauthor/app/QuestionSet.php
@@ -85,11 +85,4 @@ public function getMachineName(): string
{
return 'QuestionSet';
}
-
- protected function getTags(): array
- {
- return [
- 'h5p:' . $this->getMachineName(),
- ];
- }
}
diff --git a/sourcecode/apis/contentauthor/composer.lock b/sourcecode/apis/contentauthor/composer.lock
index a67ee373b5..bb93566861 100644
--- a/sourcecode/apis/contentauthor/composer.lock
+++ b/sourcecode/apis/contentauthor/composer.lock
@@ -495,12 +495,12 @@
"source": {
"type": "git",
"url": "https://github.com/cerpus/php-edlib-resource-kit.git",
- "reference": "46948ce41206d701fd1de8cfcae380dfcb41ccf1"
+ "reference": "7b56ee4f87d460e97b5a971f872555702e45bbdd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/cerpus/php-edlib-resource-kit/zipball/46948ce41206d701fd1de8cfcae380dfcb41ccf1",
- "reference": "46948ce41206d701fd1de8cfcae380dfcb41ccf1",
+ "url": "https://api.github.com/repos/cerpus/php-edlib-resource-kit/zipball/7b56ee4f87d460e97b5a971f872555702e45bbdd",
+ "reference": "7b56ee4f87d460e97b5a971f872555702e45bbdd",
"shasum": ""
},
"require": {
@@ -547,7 +547,7 @@
"issues": "https://github.com/cerpus/Edlib/issues",
"source": "https://github.com/cerpus/php-edlib-resource-kit/tree/master"
},
- "time": "2025-02-13T09:11:06+00:00"
+ "time": "2025-10-30T11:21:19+00:00"
},
{
"name": "cerpus/edlib-resource-kit-laravel",
diff --git a/sourcecode/apis/contentauthor/routes/rest.php b/sourcecode/apis/contentauthor/routes/rest.php
new file mode 100644
index 0000000000..12c71e4243
--- /dev/null
+++ b/sourcecode/apis/contentauthor/routes/rest.php
@@ -0,0 +1,7 @@
+middleware(['auth.psk:X-PSK,LTI_CONSUMER_KEY']);
diff --git a/sourcecode/hub/app/Console/Commands/H5PAttachContentTypeTitle.php b/sourcecode/hub/app/Console/Commands/H5PAttachContentTypeTitle.php
new file mode 100644
index 0000000000..8dcbd09a72
--- /dev/null
+++ b/sourcecode/hub/app/Console/Commands/H5PAttachContentTypeTitle.php
@@ -0,0 +1,128 @@
+info('Select the LTI tool to fetch the missing H5P content type data from, only content connected to the selected tool are updated');
+ $tools = LtiTool::select(['id', 'name'])->pluck('name', 'id')->toArray();
+ if (count($tools) === 0) {
+ $this->info('No LTI Tools found');
+ return;
+ }else if (count($tools) > 1) {
+ $selectedTool = $this->choice(
+ 'Available LTI Tools',
+ $tools,
+ attempts: 1,
+ multiple: false
+ );
+ } else {
+ $selectedTool = array_key_first($tools);
+ if (!$this->confirm("Found one LTI Tool named '{$tools[$selectedTool]}', proceed with this?", false)) {;
+ return;
+ }
+ }
+
+ $tool = LtiTool::findorFail($selectedTool);
+ $urlParts = parse_url($tool->creator_launch_url);
+ if (!is_array($urlParts) or !array_key_exists('scheme', $urlParts) or !array_key_exists('host', $urlParts)) {
+ $this->error("Failed to extract scheme and host from LTI Tool launch url: '{$tool->creator_launch_url}'");
+ return;
+ }
+
+ $resourceCount = ContentVersion::where('lti_tool_id', $selectedTool)->count();
+ if ($resourceCount === 0) {
+ $this->info('Selected LTI Tool do not have any content versions.');
+ return;
+ }
+
+ $this->info('Number of content versions connected to the selected tool: ' . $resourceCount . '');
+
+ // 1. Get the H5P machine names used by the content
+ $tags = Tag::select('tags.id', 'tags.name')
+ ->where('prefix', '=', 'h5p')
+ ->join('content_version_tag', 'content_version_tag.tag_id', '=', 'tags.id')
+ ->join('content_versions', 'content_versions.id', '=', 'content_version_tag.content_version_id')
+ ->where('content_versions.lti_tool_id', $selectedTool)
+ ->distinct()
+ ->get()
+ ->toArray();
+
+ $this->info('Unique machine names found: ' . count($tags) . '');
+
+ // 2. Fetch the title for the machine names and create as tag
+ $url = "{$urlParts['scheme']}://{$urlParts['host']}/v1/h5p/library/title";
+ $this->info("Querying {$urlParts['host']} for content type titles");
+
+ try {
+ $client = new Client();
+ $response = $client->request('POST', $url, [
+ RequestOptions::HEADERS => [
+ 'X-PSK' => $tool->consumer_key,
+ ],
+ RequestOptions::JSON => (object)["machineNames" => collect($tags)->pluck('name')->toArray()],
+ ]);
+
+ $titles = json_decode($response->getBody()->getContents(), true);
+ $this->info('Titles received: ' . count($titles) . '');
+ $totalCount = 0;
+ foreach ($tags as $tag) {
+ $this->output->write("Processing tag '{$tag['name']}'");
+ $title = $titles[$tag['name']] ?? $tag['name'];
+
+ $this->output->write(", title '$title': ");
+ $title_tag = Tag::firstOrCreate([
+ 'prefix' => 'h5p_title',
+ 'name' => $title,
+ ]);
+
+ // 3. Connect the title tags to the content versions
+ $count = 0;
+ $changeCount = 0;
+ ContentVersion::whereIn('id', function ($query) use ($tag) {
+ $query->select('content_version_id')
+ ->from('content_version_tag')
+ ->where('tag_id', '=', $tag['id']);
+ })
+ ->where('lti_tool_id', '=', $selectedTool)
+ ->chunkById(100, function (Collection $contentVersions) use ($title_tag, &$count, &$changeCount) {
+ $count += $contentVersions->count();
+ $this->output->write(".");
+ $contentVersions->each(function (ContentVersion $contentVersion) use ($title_tag, &$changeCount) {
+ if ($contentVersion->tags()->where('id', $title_tag->id)->doesntExist()) {
+ $changeCount ++;
+ $contentVersion->tags()->attach(
+ $title_tag->id,
+ ['verbatim_name' => $title_tag->name]
+ );
+ }
+ });
+ });
+ $totalCount += $count;
+ $this->output->write(" Created new tag for $changeCount of total $count content versions", newline: true);
+ }
+ $this->output->write("Total content versions processed: $totalCount", newline: true);
+ } catch (ClientException $e) {
+ $this->error($e->getMessage());
+ return;
+ }
+ }
+}
diff --git a/sourcecode/hub/app/Console/Commands/H5PDisplayName.php b/sourcecode/hub/app/Console/Commands/H5PDisplayName.php
new file mode 100644
index 0000000000..99c51649a1
--- /dev/null
+++ b/sourcecode/hub/app/Console/Commands/H5PDisplayName.php
@@ -0,0 +1,40 @@
+output->write("Configured value is: " . ($displaytype ? "$displaytype" : " - Not set -"), newline: true);
+ match($displaytype) {
+ 'h5p' => $this->info('Content type machine name is displayed as content type'),
+ 'h5p_title' => $this->info('Content type title is displayed as content type'),
+ default => $this->info('Value is unset or invalid. Default is displaying content type machine name as content type'),
+ };
+
+ if ($this->option('migrate')) {
+ if ($this->option('queue')) {
+ $dispatcher->dispatch(new \App\Jobs\SwapH5PTypeDisplayName());
+ $this->info('The migration has been queued');
+
+ return;
+ }
+
+ $this->info('Migrating content type display value...');
+ $dispatcher->dispatchSync(new \App\Jobs\SwapH5PTypeDisplayName());
+ $this->info('Done.');
+ }
+ }
+}
diff --git a/sourcecode/hub/app/Console/Commands/SearchIndexRebuild.php b/sourcecode/hub/app/Console/Commands/SearchIndexRebuild.php
new file mode 100644
index 0000000000..f761fb1459
--- /dev/null
+++ b/sourcecode/hub/app/Console/Commands/SearchIndexRebuild.php
@@ -0,0 +1,29 @@
+confirm('While rebuilding the index, listings and search will not display any content! Continue?', false)) {
+ if ($this->option('queue')) {
+ $dispatcher->dispatch(new RebuildContentIndex());
+ $this->info('The Meilisearch rebuild has been queued');
+ } else {
+ $this->info('Rebuilding Meilisearch index...');
+ $dispatcher->dispatchSync(new RebuildContentIndex());
+ $this->info('Done');
+ }
+ } else {
+ $this->info('Cancelled');
+ }
+ }
+}
diff --git a/sourcecode/hub/app/Jobs/SwapH5PTypeDisplayName.php b/sourcecode/hub/app/Jobs/SwapH5PTypeDisplayName.php
new file mode 100644
index 0000000000..8632483a6a
--- /dev/null
+++ b/sourcecode/hub/app/Jobs/SwapH5PTypeDisplayName.php
@@ -0,0 +1,54 @@
+fail('SwapH5PTypeDisplayName job failed, invalid config value: ' . $displayType);
+ } else {
+ $this->updateDisplayedContentType($displayType);
+ }
+ }
+
+ private function updateDisplayedContentType(string $prefix): void
+ {
+ DB::update(<<id;
$duration = config('cache.content_versions.duration');
-
+
return Cache::remember($cacheKey, $duration, function () {
return $this->latestVersion()->first();
});
@@ -224,7 +224,7 @@ public function getCachedLatestDraftVersion(): ?ContentVersion
$cacheKey = config('cache.content_versions.latest_draft_version_key') . $this->id;
$duration = config('cache.content_versions.duration');
-
+
return Cache::remember($cacheKey, $duration, function () {
return $this->latestDraftVersion()->first();
});
@@ -241,7 +241,7 @@ public function getCachedLatestPublishedVersion(): ?ContentVersion
$cacheKey = config('cache.content_versions.latest_published_version_key') . $this->id;
$duration = config('cache.content_versions.duration');
-
+
return Cache::remember($cacheKey, $duration, function () {
return $this->latestPublishedVersion()->first();
});
@@ -266,13 +266,45 @@ public function createVersionFromLinkItem(
$version->editedBy()->associate($user);
if ($item instanceof EdlibLtiLinkItem) {
+ $displayField = config('features.ca-content-type-display');
+ $contentType = $item->getContentType(); // Content type machine name
+ $contentTypeName = $item->getContentTypeName(); // Content type title
+ $displayValue = ($displayField === 'h5p_title' ? $contentTypeName : $contentType);
$version->published = $item->isPublished() ?? true;
$version->language_iso_639_3 = strtolower($item->getLanguageIso639_3() ?? 'und');
$version->license = $item->getLicense();
$version->max_score = $item->getLineItem()?->getScoreConstraints()?->getTotalMaximum() ?? 0;
+ $displayedType = $displayValue ?? $contentType ?? null;
+ if ($displayedType) {
+ $version->displayed_content_type = $displayedType;
+ }
+
+ $version->saveQuietly();
+ // Add content type info as tags
+ if ($contentType) {
+ $version->tags()
+ ->attach(
+ Tag::firstOrCreate([
+ 'prefix' => 'h5p',
+ 'name' => strtolower($contentType),
+ ]), [
+ 'verbatim_name' => strtolower($contentType),
+ ]
+ );
+ }
+ if ($contentTypeName) {
+ $version->tags()
+ ->attach(
+ Tag::firstOrCreate([
+ 'prefix' => 'h5p_title',
+ 'name' => $contentTypeName,
+ ]), [
+ 'verbatim_name' => $contentTypeName,
+ ]
+ );
+ }
if (count($item->getTags()) > 0) {
- $version->saveQuietly();
$version->handleSerializedTags($item->getTags());
}
}
diff --git a/sourcecode/hub/app/Models/ContentVersion.php b/sourcecode/hub/app/Models/ContentVersion.php
index 236c92155f..1d6f569b83 100755
--- a/sourcecode/hub/app/Models/ContentVersion.php
+++ b/sourcecode/hub/app/Models/ContentVersion.php
@@ -280,7 +280,7 @@ public function getSerializedTags(): array
public function handleSerializedTags(array $tags): void
{
foreach ($tags as $tag) {
- // TODO: dedicated LTI-DL field for this
+ // Could be used by REST API, not used by CA
if (str_starts_with($tag, 'h5p:')) {
$this->displayed_content_type = substr($tag, 4);
}
diff --git a/sourcecode/hub/composer.lock b/sourcecode/hub/composer.lock
index 9f0e233159..aa1187c522 100644
--- a/sourcecode/hub/composer.lock
+++ b/sourcecode/hub/composer.lock
@@ -292,12 +292,12 @@
"source": {
"type": "git",
"url": "https://github.com/cerpus/php-edlib-resource-kit.git",
- "reference": "46948ce41206d701fd1de8cfcae380dfcb41ccf1"
+ "reference": "7b56ee4f87d460e97b5a971f872555702e45bbdd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/cerpus/php-edlib-resource-kit/zipball/46948ce41206d701fd1de8cfcae380dfcb41ccf1",
- "reference": "46948ce41206d701fd1de8cfcae380dfcb41ccf1",
+ "url": "https://api.github.com/repos/cerpus/php-edlib-resource-kit/zipball/7b56ee4f87d460e97b5a971f872555702e45bbdd",
+ "reference": "7b56ee4f87d460e97b5a971f872555702e45bbdd",
"shasum": ""
},
"require": {
@@ -344,7 +344,7 @@
"issues": "https://github.com/cerpus/Edlib/issues",
"source": "https://github.com/cerpus/php-edlib-resource-kit/tree/master"
},
- "time": "2025-02-13T09:11:06+00:00"
+ "time": "2025-10-30T11:21:19+00:00"
},
{
"name": "cerpus/edlib-resource-kit-laravel",
diff --git a/sourcecode/hub/config/features.php b/sourcecode/hub/config/features.php
index e431471f58..54bc196745 100644
--- a/sourcecode/hub/config/features.php
+++ b/sourcecode/hub/config/features.php
@@ -7,4 +7,6 @@
'forgot-password' => (bool) env('FEATURE_RESET_PASSWORD_ENABLED', true),
'noindex' => (bool) env('FEATURE_NOINDEX', false),
'social-users-are-verified' => (bool) env('FEATURE_SOCIAL_USERS_ARE_VERIFIED', false),
+ // How to display the H5P Content type: 'h5p' to use content type machine name, 'h5p_title' to use content type title
+ 'ca-content-type-display' => env('FEATURE_CA_CONTENT_TYPE_DISPLAY', 'h5p'),
];