From cb72a574a35adbaebef3ad3c4d659be622a5027c Mon Sep 17 00:00:00 2001 From: Alex Hartwig Date: Fri, 23 Oct 2026 14:45:21 +0200 Subject: [PATCH] [FEATURE] Adopt Scorm Settings from other Scorm Objects --- .../classes/class.ilObjSAHSLearningModule.php | 54 ++++ .../class.ilObjSAHSLearningModuleGUI.php | 9 + .../class.ilObjSCORMLearningModuleGUI.php | 289 ++++++++++++++++++ ...lass.ilSCORMAdoptSettingsNodeRetrieval.php | 244 +++++++++++++++ lang/ilias_en.lang | 11 + 5 files changed, 607 insertions(+) create mode 100644 components/ILIAS/ScormAicc/classes/class.ilSCORMAdoptSettingsNodeRetrieval.php diff --git a/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModule.php b/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModule.php index 9ac7aff468cf..d110da5911b3 100755 --- a/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModule.php +++ b/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModule.php @@ -1216,6 +1216,60 @@ public function getSubType(): string return $this->sub_type; } + /** + * Adopt compatible SCORM runtime/settings from another SCORM module. + * Returns false if source and target are not compatible. + */ + public function adoptSettingsFrom(ilObjSAHSLearningModule $source): bool + { + if ($source->getId() === $this->getId()) { + return false; + } + + $sub_type = $this->getSubType(); + if ($sub_type !== "scorm" && $sub_type !== "scorm2004") { + return false; + } + + if ($source->getSubType() !== $sub_type) { + return false; + } + + $this->setOpenMode($source->getOpenMode()); + $this->setWidth($source->getWidth()); + $this->setHeight($source->getHeight()); + $this->setAuto_last_visited($source->getAuto_last_visited()); + $this->setDefaultLessonMode($source->getDefaultLessonMode()); + $this->setAutoReviewChar($source->getAutoReviewChar()); + $this->setMasteryScore($source->getMasteryScore()); + $this->setSession($source->getSession()); + $this->setInteractions($source->getInteractions()); + $this->setObjectives($source->getObjectives()); + $this->setTime_from_lms($source->getTime_from_lms()); + $this->setCheck_values($source->getCheck_values()); + $this->setAutoSuspend($source->getAutoSuspend()); + $this->setDebug($source->getDebug()); + $this->setIdSetting($source->getIdSetting()); + $this->setNameSetting($source->getNameSetting()); + + if ($sub_type === "scorm") { + $this->setAutoContinue($source->getAutoContinue()); + return true; + } + + if ($sub_type === "scorm2004") { + $this->setIe_force_render($source->getIe_force_render()); + $this->setNoMenu($source->getNoMenu()); + $this->setHideNavig($source->getHideNavig()); + $this->setFourth_edition($source->getFourth_Edition()); + $this->setSequencing($source->getSequencing()); + $this->setComments($source->getComments()); + return true; + } + + return false; + } + /** * delete SCORM learning module and all related data * diff --git a/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModuleGUI.php b/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModuleGUI.php index fb85866bf27b..884aa6a5902c 100755 --- a/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModuleGUI.php +++ b/components/ILIAS/ScormAicc/classes/class.ilObjSAHSLearningModuleGUI.php @@ -1042,6 +1042,15 @@ public function setSettingsSubTabs(): void get_class($this) ); + if (method_exists($this, 'adoptSettings')) { + $ilTabs->addSubTabTarget( + "sahs_adopt_settings", + $this->ctrl->getLinkTarget($this, "adoptSettings"), + array("edit", ""), + get_class($this) + ); + } + $validator = new ilCertificateActiveValidator(); if (true === $validator->validate()) { // // create and insert object in objecttree diff --git a/components/ILIAS/ScormAicc/classes/class.ilObjSCORMLearningModuleGUI.php b/components/ILIAS/ScormAicc/classes/class.ilObjSCORMLearningModuleGUI.php index 5f1a2b8d2e17..0a99ec9b9bfd 100755 --- a/components/ILIAS/ScormAicc/classes/class.ilObjSCORMLearningModuleGUI.php +++ b/components/ILIAS/ScormAicc/classes/class.ilObjSCORMLearningModuleGUI.php @@ -21,6 +21,10 @@ use ILIAS\DI\Container; use ILIAS\Repository\Filter\FilterAdapterGUI; use ILIAS\UI\Component\Dropdown\Standard as DropdownStandard; +use ILIAS\Data\Factory as DataFactory; +use ILIAS\Filesystem\Stream\Streams; +use ILIAS\UI\URLBuilder; +use ILIAS\UI\URLBuilderToken; /** * Class ilObjSCORMLearningModuleGUI @@ -48,6 +52,10 @@ class ilObjSCORMLearningModuleGUI extends ilObjSAHSLearningModuleGUI protected int $refId; protected ilCtrl $ctrl; + protected const ADOPT_SOURCE_REF_ID = 'source_ref_id'; + protected const ADOPT_TREE_PARENT_REF_ID = 'sahs_adopt_tree_parent_ref_id'; + protected const ADOPT_TREE_MAX_SYNC_DEPTH = 2; + /** * Constructor */ @@ -373,6 +381,287 @@ public function getPropertiesFormValues(): void $this->form->setValuesByArray($values); } + public function adoptSettings(): void + { + + if (!$this->dic->access()->checkAccess("write", "", $this->object->getRefId())) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt("msg_no_perm_write"), true); + ilObjectGUI::_gotoRepositoryRoot(); + return; + } + + $this->setSettingsSubTabs(); + $this->dic->tabs()->setSubTabActive('sahs_adopt_settings'); + $this->renderAdoptSettingsForm($this->buildAdoptSettingsForm()); + } + + public function applyAdoptSettings(): void + { + + if (!$this->dic->access()->checkAccess("write", "", $this->object->getRefId())) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt("msg_no_perm_write"), true); + ilObjectGUI::_gotoRepositoryRoot(); + return; + } + + $this->setSettingsSubTabs(); + $this->dic->tabs()->setSubTabActive('sahs_adopt_settings'); + + $form = $this->buildAdoptSettingsForm()->withRequest($this->dic->http()->request()); + $data = $form->getData(); + + if (!is_array($data)) { + $this->renderAdoptSettingsForm($form); + return; + } + + $source_ref_id = $this->extractSourceRefId($data[self::ADOPT_SOURCE_REF_ID] ?? null); + if ($source_ref_id <= 0) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('sahs_adopt_settings_invalid_source'), true); + $this->ctrl->redirect($this, 'adoptSettings'); + } + + $source_obj_id = ilObject::_lookupObjectId($source_ref_id); + if ($source_obj_id === $this->object->getId()) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('sahs_adopt_settings_invalid_source'), true); + $this->ctrl->redirect($this, 'adoptSettings'); + } + + if (!$this->dic->access()->checkAccess("write", "", $source_ref_id)) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('sahs_adopt_settings_source_no_write'), true); + $this->ctrl->redirect($this, 'adoptSettings'); + } + + if ($source_obj_id <= 0 || ilObject::_lookupType($source_obj_id) !== "sahs") { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('sahs_adopt_settings_invalid_source'), true); + $this->ctrl->redirect($this, 'adoptSettings'); + } + + $target_subtype = $this->object->getSubType(); + $source_subtype = ilObjSAHSLearningModule::_lookupSubType($source_obj_id); + if ($target_subtype !== $source_subtype) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('sahs_adopt_settings_subtype_mismatch'), true); + $this->ctrl->redirect($this, 'adoptSettings'); + } + + $source = new ilObjSAHSLearningModule($source_ref_id, true); + + if (!$this->object->adoptSettingsFrom($source)) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('sahs_adopt_settings_subtype_mismatch'), true); + $this->ctrl->redirect($this, 'adoptSettings'); + } + $this->object->update(); + + $this->tpl->setOnScreenMessage('success', $this->lng->txt('sahs_adopt_settings_success'), true); + $this->ctrl->redirect($this, 'properties'); + } + + public function loadAdoptSettingsTreeNodes(): void + { + if (!$this->ctrl->isAsynch()) { + throw new \RuntimeException(self::class . '::' . __FUNCTION__ . ' must be called asynchronously.'); + } + + if (!$this->dic->access()->checkAccess("write", "", $this->object->getRefId())) { + $this->sendAdoptSettingsTreeResponse(''); + return; + } + + [, $parent_id_parameter] = $this->buildAdoptSettingsTreeUrlBuilder(); + $parent_id_parameter_name = $parent_id_parameter->getName(); + + $query = $this->dic->http()->wrapper()->query(); + if (!$query->has($parent_id_parameter_name)) { + $this->sendAdoptSettingsTreeResponse(''); + return; + } + + $parent_ref_id = (int) $query->retrieve( + $parent_id_parameter_name, + $this->dic->refinery()->kindlyTo()->int() + ); + if ($parent_ref_id <= 0) { + $this->sendAdoptSettingsTreeResponse(''); + return; + } + + $factory = $this->dic->ui()->factory(); + $renderer = $this->dic->ui()->renderer(); + $node_retrieval = $this->buildAdoptSettingsNodeRetrieval(); + $node_generator = $node_retrieval->getNodes( + $factory->input()->field()->node(), + $factory->symbol()->icon(), + [], + (string) $parent_ref_id, + ); + + $html = ''; + foreach ($node_generator as $node) { + $html .= $renderer->renderAsync($node); + } + + $this->sendAdoptSettingsTreeResponse($html); + } + + protected function buildAdoptSettingsForm(): \ILIAS\UI\Component\Input\Container\Form\Standard + { + $factory = $this->dic->ui()->factory(); + + + $source_input = $factory->input()->field()->treeSelect( + $this->buildAdoptSettingsNodeRetrieval(), + $this->lng->txt('sahs_adopt_settings_source'), + $this->lng->txt('sahs_adopt_settings_source_info') + )->withAdditionalTransformation( + $this->dic->refinery()->custom()->constraint( + static function ($value): bool { + if (is_array($value) && isset($value[0])) { + return (int) $value[0] > 0; + } + if (is_scalar($value)) { + return (int) $value > 0; + } + return false; + }, + $this->lng->txt('sahs_adopt_settings_source_required') + ) + )->withAdditionalOnLoadCode( + static function (string $id): string { + return << { + tree_select.querySelectorAll('.c-input-node:not(.c-input-node__leaf) > .c-input-node__select') + .forEach((button) => { + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + const glyph = button.querySelector('.glyph'); + if (glyph) { + glyph.classList.add('disabled'); + } + const node = button.closest('.c-input-node'); + const drilldown_trigger = node + ? node.querySelector('.c-drilldown__menulevel--trigger') + : null; + if (drilldown_trigger instanceof HTMLElement) { + drilldown_trigger.style.setProperty('width', '100%', 'important'); + } + button.hidden = true; + button.style.display = 'none'; + button.setAttribute('aria-hidden', 'true'); + button.tabIndex = -1; + button.style.pointerEvents = 'none'; + }); + }; + if (typeof MutationObserver === 'function') { + new MutationObserver(hide_non_leaf_selection).observe(tree_select, {childList: true, subtree: true}); + } + + hide_non_leaf_selection(); + } +} +JS; + } + ); + + return $factory->input()->container()->form()->standard( + $this->ctrl->getFormAction($this, 'applyAdoptSettings'), + [self::ADOPT_SOURCE_REF_ID => $source_input], + )->withSubmitLabel($this->lng->txt('sahs_adopt_settings_apply')); + } + + protected function renderAdoptSettingsForm(\ILIAS\UI\Component\Input\Container\Form\Standard $form): void + { + $factory = $this->dic->ui()->factory(); + $renderer = $this->dic->ui()->renderer(); + + $description = $factory->legacy()->content( + '

' . htmlspecialchars($this->lng->txt('sahs_adopt_settings_description'), ENT_QUOTES, 'UTF-8') . '

' + ); + $panel = $factory->panel()->standard( + $this->lng->txt('sahs_adopt_settings_title'), + [$description, $form], + ); + $this->tpl->setContent($renderer->render($panel)); + } + + protected function buildAdoptSettingsNodeRetrieval(): ilSCORMAdoptSettingsNodeRetrieval + { + $data_factory = new DataFactory(); + [$url_builder, $parent_id_parameter] = $this->buildAdoptSettingsTreeUrlBuilder(); + + $async_uri_provider = static function (int $parent_ref_id) use ( + $data_factory, + $url_builder, + $parent_id_parameter + ): \ILIAS\Data\URI { + return $data_factory->uri( + (string) $url_builder + ->withParameter($parent_id_parameter, (string) $parent_ref_id) + ->buildURI() + ); + }; + + return new ilSCORMAdoptSettingsNodeRetrieval( + $this->object->getRefId(), + $this->object->getSubType(), + $async_uri_provider, + $this->dic->language(), + $this->dic->access(), + $this->dic->repositoryTree(), + $this->dic['objDefinition']->getExplorerContainerTypes(), + self::ADOPT_TREE_MAX_SYNC_DEPTH, + ); + } + + /** + * @return array{0: URLBuilder, 1: URLBuilderToken} + */ + protected function buildAdoptSettingsTreeUrlBuilder(): array + { + $data_factory = new DataFactory(); + $url_builder = new URLBuilder( + $data_factory->uri( + ILIAS_HTTP_PATH . '/' . $this->ctrl->getLinkTarget( + $this, + 'loadAdoptSettingsTreeNodes', + null, + true + ) + ) + ); + + return $url_builder->acquireParameter( + ['sahs', 'adopt', 'settings'], + self::ADOPT_TREE_PARENT_REF_ID, + ); + } + + protected function sendAdoptSettingsTreeResponse(string $html): void + { + $http = $this->dic->http(); + $http->saveResponse( + $http->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) + ); + $http->sendResponse(); + $http->close(); + } + + protected function extractSourceRefId(mixed $value): int + { + if (is_array($value) && isset($value[0])) { + return (int) $value[0]; + } + if (is_scalar($value)) { + return (int) $value; + } + return 0; + } + /** * upload new version of module */ diff --git a/components/ILIAS/ScormAicc/classes/class.ilSCORMAdoptSettingsNodeRetrieval.php b/components/ILIAS/ScormAicc/classes/class.ilSCORMAdoptSettingsNodeRetrieval.php new file mode 100644 index 000000000000..7d6bc9b25ba5 --- /dev/null +++ b/components/ILIAS/ScormAicc/classes/class.ilSCORMAdoptSettingsNodeRetrieval.php @@ -0,0 +1,244 @@ +current_obj_id = ilObject::_lookupObjectId($current_ref_id); + $this->max_branch_node_depth = max(0, $max_branch_node_depth); + $this->node_object_types = array_values( + array_unique( + [...$this->branch_object_types, 'sahs'], + ), + ); + } + + public function getNodes( + NodeFactory $node_factory, + IconFactory $icon_factory, + array $sync_node_id_whitelist = [], + ?string $parent_id = null, + ): \Generator { + yield from $this->createNodes( + $node_factory, + $icon_factory, + $sync_node_id_whitelist, + (int) ($parent_id ?? $this->tree->getParentId($this->tree->getRootId())), + ); + } + + public function getNodesAsLeaf( + NodeFactory $node_factory, + IconFactory $icon_factory, + array $node_ids, + ): \Generator { + foreach ($node_ids as $node_id) { + $ref_id = (int) $node_id; + if (!$this->isSelectableSource($ref_id)) { + yield $node_factory->leaf([(string) $node_id], $this->language->txt('unknown')); + continue; + } + + $node_data = $this->tree->getNodeData($ref_id); + $path = $this->tree->getPathId($ref_id); + $title = (string) ($node_data['title'] ?? ''); + if ($path === [] || $title === '') { + yield $node_factory->leaf([(string) $node_id], $this->language->txt('unknown')); + continue; + } + + yield $node_factory->leaf( + $path, + $title, + $this->getObjectIcon($icon_factory, $ref_id, 'sahs'), + ); + } + } + + /** + * @return \ILIAS\UI\Component\Input\Field\Node\Node[] + */ + protected function createNodes( + NodeFactory $node_factory, + IconFactory $icon_factory, + array $sync_node_id_whitelist, + int $parent_ref_id, + int $depth = 0, + ): array { + $nodes = []; + $children = $this->tree->getChildsByTypeFilter( + $parent_ref_id, + $this->node_object_types, + self::NODE_SORT_BY, + ); + + foreach ($children as $child) { + $ref_id = (int) ($child['ref_id'] ?? $child['child'] ?? 0); + $type = (string) ($child['type'] ?? ''); + $title = (string) ($child['title'] ?? ''); + $path = $this->tree->getPathId($ref_id); + + if ($ref_id <= 0 || $path === [] || $title === '') { + continue; + } + + $is_visible = $this->isObjectVisible($ref_id, $type); + $is_container = $this->isObjectContainer($type); + + // Append visible children of an invisible branch node to the current level. + if (!$is_visible && $is_container) { + $nodes = [ + ...$nodes, + ...$this->createNodes( + $node_factory, + $icon_factory, + $sync_node_id_whitelist, + $ref_id, + $depth + 1, + ), + ]; + continue; + } + + if ($is_visible && $is_container) { + $icon = $this->getObjectIcon($icon_factory, $ref_id, $type); + if ($depth < $this->max_branch_node_depth || in_array($ref_id, $sync_node_id_whitelist, true)) { + $child_nodes = $this->createNodes( + $node_factory, + $icon_factory, + $sync_node_id_whitelist, + $ref_id, + $depth + 1, + ); + if ($child_nodes !== []) { + $nodes[] = $node_factory->branch( + $path, + $title, + $icon, + ...$child_nodes, + ); + } + } elseif ($this->hasDisplayableChildren($ref_id)) { + $nodes[] = $node_factory->async( + $this->getAsyncRenderUri($ref_id), + $path, + $title, + $icon, + ); + } + continue; + } + + if ($is_visible && $type === 'sahs' && $this->isSelectableSource($ref_id)) { + $nodes[] = $node_factory->leaf( + $path, + $title, + $this->getObjectIcon($icon_factory, $ref_id, $type), + ); + } + } + + return $nodes; + } + + protected function getObjectIcon(IconFactory $icon_factory, int $object_ref_id, string $object_type): Icon + { + $icon_path = ilObject::_getIcon(ilObject::_lookupObjectId($object_ref_id), Icon::SMALL); + $alt_text = $this->tree->getRootId() >= $object_ref_id + ? $this->language->txt('repository') + : $this->language->txt($object_type); + + return $icon_factory->custom($icon_path, $alt_text, Icon::SMALL); + } + + protected function getAsyncRenderUri(int $parent_ref_id): URI + { + $provider = $this->async_uri_provider; + return $provider($parent_ref_id); + } + + protected function isSelectableSource(int $ref_id): bool + { + if ($ref_id <= 0 || $ref_id === $this->current_ref_id) { + return false; + } + + $object_id = ilObject::_lookupObjectId($ref_id); + if ( + $object_id <= 0 + || $object_id === $this->current_obj_id + || ilObject::_lookupType($object_id) !== 'sahs' + ) { + return false; + } + + if (!$this->access->checkAccess('write', '', $ref_id, 'sahs')) { + return false; + } + + return ilObjSAHSLearningModule::_lookupSubType($object_id) === $this->target_subtype; + } + + protected function hasDisplayableChildren(int $parent_ref_id): bool + { + return $this->tree->getChildsByTypeFilter( + $parent_ref_id, + $this->node_object_types, + self::NODE_SORT_BY, + ) !== []; + } + + protected function isObjectVisible(int $object_ref_id, string $object_type = ''): bool + { + return $this->access->checkAccess('visible', '', $object_ref_id, $object_type); + } + + protected function isObjectContainer(string $object_type): bool + { + return in_array($object_type, $this->branch_object_types, true); + } +} diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 556f3c605185..55338b05578c 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -15235,6 +15235,17 @@ rwik#:#rwik_call#:#Call ECS Wiki sahs#:#cont_insert_after_chap#:#Insert items after chapter sahs#:#cont_insert_into_chap#:#Insert items inside chapter sahs#:#sahs_add#:#Create SCORM Learning Module +sahs#:#sahs_adopt_settings#:#Adopt Settings +sahs#:#sahs_adopt_settings_apply#:#Adopt Settings +sahs#:#sahs_adopt_settings_description#:#Select another SCORM object to adopt compatible SCORM settings. +sahs#:#sahs_adopt_settings_invalid_source#:#Please select a different SCORM object as source. +sahs#:#sahs_adopt_settings_source#:#Source SCORM Object +sahs#:#sahs_adopt_settings_source_info#:#Only SCORM objects of the same subtype are selectable. +sahs#:#sahs_adopt_settings_source_no_write#:#You need Edit Settings permission on the selected source object. +sahs#:#sahs_adopt_settings_source_required#:#Please select a source SCORM object. +sahs#:#sahs_adopt_settings_subtype_mismatch#:#Settings can only be adopted between SCORM objects of the same subtype. +sahs#:#sahs_adopt_settings_success#:#SCORM settings were adopted successfully. +sahs#:#sahs_adopt_settings_title#:#Adopt Settings sahs#:#sahs_authoring_mode#:#Authoring Mode sahs#:#sahs_authoring_mode_info#:#The SCORM ZIP package must have been created with the ILIAS SCORM editor and it must have been exported as SCORM 1.2/2004 package. sahs#:#sahs_chapter#:#Chapter