From bf28c2f2c3ea495f1dea86e1a59adfc3b76090ba Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Fri, 10 Oct 2025 01:06:20 -0500 Subject: [PATCH 1/2] Course: Improve and fix course maintenance (import/copy/recycle/delete) - refs #6870 --- .../coursemaintenance/ResourceSelector.vue | 234 +- .../components/glossary/GlossaryTermList.vue | 10 +- .../components/glossary/GlossaryTermTable.vue | 16 +- public/main/course_progress/index.php | 20 +- public/main/inc/lib/thematic.lib.php | 8 +- .../course_progress/progress.html.twig | 343 +- .../CourseMaintenanceController.php | 676 ++- src/CoreBundle/Helpers/ChamiloHelper.php | 281 +- .../Component/CourseCopy/CourseRecycler.php | 74 +- .../Component/CourseCopy/CourseRestorer.php | 5395 ++++++++++------- 10 files changed, 4609 insertions(+), 2448 deletions(-) diff --git a/assets/vue/components/coursemaintenance/ResourceSelector.vue b/assets/vue/components/coursemaintenance/ResourceSelector.vue index ed43867f5e0..b1e293697e0 100644 --- a/assets/vue/components/coursemaintenance/ResourceSelector.vue +++ b/assets/vue/components/coursemaintenance/ResourceSelector.vue @@ -4,13 +4,14 @@

{{ title }}

- - {{ selectedTotal }} {{ $t('selected') }} - + {{ selectedTotal }} {{ $t("selected") }}
-
+
+ @click="query = ''" + :aria-label="$t('Clear search')" + >
- - - -
@@ -43,11 +57,17 @@
-
+
{{ emptyText }}
-
+
@@ -85,43 +105,73 @@ const emit = defineEmits(["update:modelValue"]) // hook with shared logic const sel = useResourceSelection() -const { tree, selections, query, forceOpen, - normalizeTreeForSelection, filteredGroups, selectedTotal, - countSelected, isNodeCheckable, isChecked, toggleNode, checkAll, expandAll } = sel +const { + tree, + selections, + query, + forceOpen, + normalizeTreeForSelection, + filteredGroups, + selectedTotal, + countSelected, + isNodeCheckable, + isChecked, + toggleNode, + checkAll, + expandAll, +} = sel // sync in/out const { groups, modelValue } = toRefs(props) -watch(groups, (arr) => { - const norm = normalizeTreeForSelection(Array.isArray(arr) ? JSON.parse(JSON.stringify(arr)) : []) - // ensure top-level children - tree.value = norm.map(g => - Array.isArray(g.children) ? g : { ...g, children: Array.isArray(g.items) ? g.items : [] } - ) -}, { immediate: true }) +watch( + groups, + (arr) => { + const norm = normalizeTreeForSelection(Array.isArray(arr) ? JSON.parse(JSON.stringify(arr)) : []) + // ensure top-level children + tree.value = norm.map((g) => + Array.isArray(g.children) ? g : { ...g, children: Array.isArray(g.items) ? g.items : [] }, + ) + }, + { immediate: true }, +) // auto expand on first data watch(tree, (v) => { if (Array.isArray(v) && v.length) { forceOpen.value = true - requestAnimationFrame(() => { forceOpen.value = null }) + requestAnimationFrame(() => { + forceOpen.value = null + }) } }) let syncing = false -watch(modelValue, (v) => { - if (syncing) return - syncing = true - selections.value = { ...(v || {}) } - queueMicrotask(() => { syncing = false }) -}, { immediate: true }) - -watch(selections, (v) => { - if (syncing) return - syncing = true - emit("update:modelValue", { ...(v || {}) }) - queueMicrotask(() => { syncing = false }) -}, { deep: true }) +watch( + modelValue, + (v) => { + if (syncing) return + syncing = true + selections.value = { ...(v || {}) } + queueMicrotask(() => { + syncing = false + }) + }, + { immediate: true }, +) + +watch( + selections, + (v) => { + if (syncing) return + syncing = true + emit("update:modelValue", { ...(v || {}) }) + queueMicrotask(() => { + syncing = false + }) + }, + { deep: true }, +) diff --git a/public/main/course_progress/index.php b/public/main/course_progress/index.php index ab7f867603e..43a587d3586 100644 --- a/public/main/course_progress/index.php +++ b/public/main/course_progress/index.php @@ -585,9 +585,9 @@ function update_done_thematic_advance(selected_value) { if (api_is_allowed_to_edit(null, true)) { // Thematic title $toolbarThematic = Display::url( - Display::getMdiIcon('disc', 'ch-tool-icon', null, ICON_SIZE_TINY, get_lang('Copy')), + Display::getMdiIcon('disc', 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Copy')), 'index.php?'.api_get_cidreq().'&action=thematic_copy&thematic_id='.$id.$params.$url_token, - ['class' => 'btn btn--plain'] + ['class' => 'btn btn--default'] ); if (0 == api_get_session_id()) { $link = $thematic->getResourceNode()->getResourceLinkByContext($course, $session); @@ -599,33 +599,33 @@ function update_done_thematic_advance(selected_value) { if (true) { //if (api_get_session_id() == $thematic->getSessionId()) { $toolbarThematic .= Display::url( - Display::getMdiIcon(ActionIcon::EXPORT_PDF, 'ch-tool-icon', null, ICON_SIZE_TINY, get_lang('Export to PDF')), + Display::getMdiIcon(ActionIcon::EXPORT_PDF, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export to PDF')), api_get_self().'?'.api_get_cidreq()."$url_token&".http_build_query( [ 'action' => 'export_single_thematic', 'thematic_id' => $id, ] ), - ['class' => 'btn btn--plain'] + ['class' => 'btn btn--default'] ); /*$toolbarThematic .= Display::url( - Display::getMdiIcon(ActionIcon::EXPORT_DOC, 'ch-tool-icon', null, ICON_SIZE_TINY, get_lang('Export latest version of this page to Documents')), + Display::getMdiIcon(ActionIcon::EXPORT_DOC, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export latest version of this page to Documents')), api_get_self().'?'.api_get_cidreq().$url_token.'&'.http_build_query( ['action' => 'export_single_documents', 'thematic_id' => $id] ), - ['class' => 'btn btn--plain'] + ['class' => 'btn btn--default'] );*/ $toolbarThematic .= '' - .Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_TINY, get_lang('Edit')).''; + .Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Edit')).''; $toolbarThematic .= '' - .Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_TINY, get_lang('Delete')).''; + .Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Delete')).''; } } $extra[$thematic->getIid()]['toolbar'] = $toolbarThematic; diff --git a/public/main/inc/lib/thematic.lib.php b/public/main/inc/lib/thematic.lib.php index 95c73f02f66..cd8b84e6947 100644 --- a/public/main/inc/lib/thematic.lib.php +++ b/public/main/inc/lib/thematic.lib.php @@ -126,15 +126,15 @@ public function getMoveActions(int $thematicId, int $currentOrder, int $maxOrder $params = '&thematic_id=' . $thematicId . '&sec_token=' . Security::get_token(); if ($currentOrder > 0) { - $toolbarThematic .= '' . Display::getMdiIcon(ActionIcon::UP, 'ch-tool-icon', null, ICON_SIZE_TINY, get_lang('Up')) . ''; + $toolbarThematic .= '' . Display::getMdiIcon(ActionIcon::UP, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Up')) . ''; } else { - $toolbarThematic .= '
' . Display::getMdiIcon(ActionIcon::UP, 'ch-tool-icon-disabled', null, ICON_SIZE_TINY, '') . '
'; + $toolbarThematic .= '
' . Display::getMdiIcon(ActionIcon::UP, 'ch-tool-icon-disabled', null, ICON_SIZE_MEDIUM, '') . '
'; } if ($currentOrder < $maxOrder - 1) { - $toolbarThematic .= '' . Display::getMdiIcon(ActionIcon::DOWN, 'ch-tool-icon', null, ICON_SIZE_TINY, get_lang('Down')) . ''; + $toolbarThematic .= '' . Display::getMdiIcon(ActionIcon::DOWN, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Down')) . ''; } else { - $toolbarThematic .= '
' . Display::getMdiIcon(ActionIcon::DOWN, 'ch-tool-icon-disabled', null, ICON_SIZE_TINY, '') . '
'; + $toolbarThematic .= '
' . Display::getMdiIcon(ActionIcon::DOWN, 'ch-tool-icon-disabled', null, ICON_SIZE_MEDIUM, '') . '
'; } return $toolbarThematic; diff --git a/public/main/template/default/course_progress/progress.html.twig b/public/main/template/default/course_progress/progress.html.twig index f3418fc1d1f..dedab2f6c1e 100644 --- a/public/main/template/default/course_progress/progress.html.twig +++ b/public/main/template/default/course_progress/progress.html.twig @@ -1,200 +1,211 @@ {% autoescape false %} - - + } + + {% if data is not empty %} + {% set tutor = is_granted('ROLE_TEACHER') %} -{% if data is not empty %} -{% set tutor = is_granted('ROLE_TEACHER') %} -
-
-
-
-
-
-

{{ 'Progress' | trans }}: {{ score_progress }} %

-
+
+ {# Header: progress #} +
+

{{ 'Progression du cours'|trans }}

+
+ {{ 'Progress'|trans }}: + + {{ score_progress }}% +
-
- - - - - - - - - - {% for item in data %} - - - - - - {% endfor %} - -
{{ 'Thematic' | trans }}{{ 'Thematic plan' | trans }}{{ 'Thematic advance' | trans }}
- {% if session_star is not defined %} -
{{ item.title }}
- {% else %} -
{{ item.title }} {{ session_star }}
- {% endif %} - {{ item.content }} -
- {{ extra[item.iid]['toolbar'] }} -
-
-
+ + {# Loop Thematics #} +
+ {% for item in data %} +
+
+
+

+ {{ 'Thematic'|trans }} {{ '%03d'|format(loop.index) }} +

+

{{ item.title }}

+
+ +
+ {{ extra[item.iid]['toolbar'] }} +
+
+ +
+
+

+ {{ 'Thematic'|trans }} +

+
+ {{ item.content }} +
+
+ +
+
+

+ {{ 'Thematic plan'|trans }} +

{% if tutor %} - + + {{ 'ActionIcon::EDIT' | mdi_icon }} + {% endif %} +
- {% if item.plans is empty %} -
- -
- {% else %} + {% if item.plans is empty %} +
+ {{ 'There is no thematic plan for now' | trans }} +
+ {% else %} +
{% for plan in item.plans %} -
{{ plan.title }}
- {{ plan.description }} +
+
{{ plan.title }}
+
+ {{ plan.description }} +
+
{% endfor %} - {% endif %} -
-
-
- {% if tutor %} -
- +
+ {% endif %} +
+ +
+ + + {% if item.advances is empty %} +
+ {{ 'There is no thematic advance' | trans }}
- {% endif %} - - {% if item.advances is not empty %} - {% for advance in item.advances %} - - - {% if advance.doneAdvance == 1 %} - {% set color = "background-color:#E5EDF9;" %} - {% else %} - {% set color = "background-color:#FFFFFF;" %} - {% endif %} - {% if tutor %} - - {% endif %} - - {% endfor %} - {% else %} - + + {% endfor %} + {% endif %} -
-
- {{ advance.startDate | format_date }} - {{ advance.content }} -
- {% if tutor %} -
-
-
- - {{ 'ActionIcon::EDIT' | mdi_icon }} - - - {{ 'ActionIcon::DELETE' | mdi_icon }} - + {% else %} +
    + {% for advance in item.advances %} +
  1. +
    +

    + {{ advance.startDate | format_date }} +

    +
    + {{ advance.content }} +
    + + {% if tutor %} + + {% endif %} +
    + + {% set bg = (advance.doneAdvance == 1) ? 'background-color:#E5EDF9;' : 'background-color:#FFFFFF;' %} +
    +
    + {% set check = (extra[item.iid]['last_done'] == advance.iid) ? 'checked' : '' %} +
    - {% endif %}
    -
- {% set check = "" %} - {% if extra[item.iid]['last_done'] == advance.iid %} - {% set check = "checked" %} - {% endif %} - - {% else %} -
-
-
+
+
+ + {% endfor %}
-
-
-{% else %} - {% if is_allowed_to_edit %} - {{ no_data }} {% else %} - + {% if is_allowed_to_edit %} + {{ no_data }} + {% else %} +
+ {{ 'There is no thematic section' | trans }} +
+ {% endif %} {% endif %} -{% endif %} {% endautoescape %} diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php index 3d05058e345..01fecafae8b 100644 --- a/src/CoreBundle/Controller/CourseMaintenanceController.php +++ b/src/CoreBundle/Controller/CourseMaintenanceController.php @@ -20,6 +20,7 @@ use Symfony\Component\Routing\Attribute\Route; use Throwable; +use const ARRAY_FILTER_USE_BOTH; use const JSON_PRETTY_PRINT; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; @@ -183,17 +184,25 @@ public function importRestore(int $node, string $backupId, Request $req): JsonRe $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]); try { + // Read payload $payload = json_decode($req->getContent() ?: '{}', true); $importOption = (string) ($payload['importOption'] ?? 'full_backup'); $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); + + /** @var array $selectedResources */ $selectedResources = (array) ($payload['resources'] ?? []); + /** @var string[] $selectedTypes */ + $selectedTypes = array_map('strval', (array) ($payload['selectedTypes'] ?? [])); + $this->logDebug('[importRestore] input', [ 'importOption' => $importOption, 'sameFileNameOption' => $sameFileNameOption, - 'selectedTypes' => array_keys($selectedResources), + 'selectedTypes' => $selectedTypes, + 'hasResourcesMap' => !empty($selectedResources), ]); + // Resolve file path $backupDir = CourseArchiver::getBackupDir(); $this->logDebug('[importRestore] backup dir', $backupDir); $path = rtrim($backupDir, '/').'/'.$backupId; @@ -203,6 +212,7 @@ public function importRestore(int $node, string $backupId, Request $req): JsonRe 'readable' => is_readable($path), ]); + // Load legacy Course /** @var Course $course */ $course = CourseArchiver::readCourse($backupId, false); @@ -212,10 +222,31 @@ public function importRestore(int $node, string $backupId, Request $req): JsonRe return $this->json(['error' => 'Backup has no resources'], 400); } + // Snapshots before filtering (debug) $this->logDebug('[importRestore] BEFORE filter keys', array_keys($course->resources)); $this->logDebug('[importRestore] BEFORE forum counts', $this->snapshotForumCounts($course)); + $this->logDebug('[importRestore] BEFORE resources snapshot', $this->snapshotResources($course)); + + $resourcesAll = (array) ($course->resources ?? []); + $this->logDebug('[importRestore] resources_all snapshot captured', ['keys' => array_keys($resourcesAll)]); + // Partial selection logic if ('select_items' === $importOption) { + $this->hydrateLpDependenciesFromSnapshot($course, $resourcesAll ?? []); + + // If the UI sent only high-level types (e.g., ["learnpath"]) and no item map, + // build a resources selection map from those types. + if (empty($selectedResources) && !empty($selectedTypes)) { + if (method_exists($this, 'buildSelectionFromTypes')) { + $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes); + } + $this->logDebug('[importRestore] built selection from types', [ + 'selectedTypes' => $selectedTypes, + 'built_keys' => array_keys($selectedResources), + ]); + } + + // Validate selection map $hasAny = false; foreach ($selectedResources as $t => $ids) { if (\is_array($ids) && !empty($ids)) { @@ -230,6 +261,9 @@ public function importRestore(int $node, string $backupId, Request $req): JsonRe return $this->json(['error' => 'No resources selected'], 400); } + // Filter legacy course by selection (keeps only selected buckets/ids). + // Dependency pulling for LP/quizzes/surveys/etc. should be handled inside the Restorer, + // using the full snapshot we pass below (no dynamic properties on $course). $course = $this->filterLegacyCourseBySelection($course, $selectedResources); if (empty($course->resources) || 0 === \count((array) $course->resources)) { @@ -239,25 +273,33 @@ public function importRestore(int $node, string $backupId, Request $req): JsonRe } } + // Snapshots after filtering (debug) $this->logDebug('[importRestore] AFTER filter keys', array_keys($course->resources)); $this->logDebug('[importRestore] AFTER forum counts', $this->snapshotForumCounts($course)); $this->logDebug('[importRestore] AFTER resources snapshot', $this->snapshotResources($course)); + // Restore $restorer = new CourseRestorer($course); $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption)); + + if (method_exists($restorer, 'setResourcesAllSnapshot')) { + $restorer->setResourcesAllSnapshot($resourcesAll); + $this->logDebug('[importRestore] restorer snapshot forwarded', ['keys' => array_keys($resourcesAll)]); + } if (method_exists($restorer, 'setDebug')) { $restorer->setDebug($this->debug); - $this->logDebug('[importRestore] restorer debug forwarded', ['debug' => $this->debug]); } - $this->logDebug('[importRestore] calling restore()'); $restorer->restore(); + $this->logDebug('[importRestore] restore() finished', [ 'dest_course_id' => $restorer->destination_course_info['real_id'] ?? null, ]); + // Cleanup temporary backup dir CourseArchiver::cleanBackupDir(); + // Redirect info $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0); $sessionId = 0; $groupId = 0; @@ -725,7 +767,6 @@ public function cc13Import(int $node, Request $req): JsonResponse } // TODO: Parse/restore CC 1.3. For now, just acknowledge. - // You can temporarily move the uploaded file into a working dir if useful. return $this->json([ 'ok' => true, 'message' => 'CC 1.3 import endpoint is under construction. File received successfully.', @@ -736,6 +777,131 @@ public function cc13Import(int $node, Request $req): JsonResponse // Helpers to build the Vue-ready resource tree // -------------------------------------------------------------------------------- + /** + * Copies the dependencies (document, link, quiz, etc.) to $course->resources + * that reference the selected LearnPaths, taking the items from the full snapshot. + * + * It doesn't break anything if something is missing or comes in a different format: it's defensive. + */ + private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void + { + if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) { + return; + } + + $depTypes = [ + 'document', 'link', 'quiz', 'work', 'survey', + 'Forum_Category', 'forum', 'thread', 'post', + 'Exercise_Question', 'survey_question', 'Link_Category', + ]; + + $need = []; + $addNeed = function (string $type, $id) use (&$need): void { + $t = (string) $type; + $i = is_numeric($id) ? (int) $id : (string) $id; + if ('' === $i || 0 === $i) { + return; + } + $need[$t] ??= []; + $need[$t][$i] = true; + }; + + foreach ($course->resources['learnpath'] as $lpId => $lpWrap) { + $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap; + + if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) { + foreach ($lpWrap->linked_resources as $t => $ids) { + if (!\is_array($ids)) { + continue; + } + foreach ($ids as $rid) { + $addNeed($t, $rid); + } + } + } + + $items = []; + if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) { + $items = $lp->items; + } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) { + $items = $lpWrap->items; + } + + foreach ($items as $it) { + $ito = \is_object($it) ? $it : (object) $it; + + if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) { + foreach ($ito->linked_resources as $t => $ids) { + if (!\is_array($ids)) { + continue; + } + foreach ($ids as $rid) { + $addNeed($t, $rid); + } + } + } + + foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) { + if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) { + $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field}; + $t = $typeGuess ?: (string) ($ito->type ?? ''); + if ('' !== $t) { + $addNeed($t, $rid); + } + } + } + + if (!empty($ito->type) && isset($ito->ref)) { + $addNeed((string) $ito->type, $ito->ref); + } + } + } + + if (empty($need)) { + $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category']; + foreach ($core as $k) { + if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) { + $course->resources[$k] ??= []; + if (0 === \count($course->resources[$k])) { + $course->resources[$k] = $snapshot[$k]; + } + } + } + $this->logDebug('[LP-deps] fallback filled from snapshot', [ + 'bags' => array_keys(array_filter($course->resources, fn ($v, $k) => \in_array($k, $core, true) && \is_array($v) && \count($v) > 0, ARRAY_FILTER_USE_BOTH)), + ]); + + return; + } + + foreach ($need as $type => $idMap) { + if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) { + continue; + } + + $course->resources[$type] ??= []; + + foreach (array_keys($idMap) as $rid) { + $src = $snapshot[$type][$rid] + ?? $snapshot[$type][(string) $rid] + ?? null; + + if (!$src) { + continue; + } + + if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) { + $course->resources[$type][$rid] = $src; + } + } + } + + $this->logDebug('[LP-deps] hydrated', [ + 'types' => array_keys($need), + 'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)), + ]); + } + /** * Build a Vue-friendly tree from legacy Course. */ @@ -779,6 +945,21 @@ private function buildResourceTreeForVue(object $course): array $skipTypes['post'] = true; } + // Links block (Category → Link) + $hasLinkData = + (!empty($resources['link']) || !empty($resources['Link'])) + || (!empty($resources['link_category']) || !empty($resources['Link_Category'])); + + if ($hasLinkData) { + $tree[] = $this->buildLinkTreeForVue( + $course, + $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links') + ); + // Prevent generic loop from adding separate "link" and "link_category" groups + $skipTypes['link'] = true; + $skipTypes['link_category'] = true; + } + // Other tools foreach ($resources as $rawType => $items) { if (!\is_array($items) || empty($items)) { @@ -823,6 +1004,21 @@ private function buildResourceTreeForVue(object $course): array } $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0); + if ('document' === $typeKey) { + $e = $this->objectEntity($obj); + $rawPath = (string) ($e->path ?? ''); + if ('' !== $rawPath) { + $rel = ltrim($rawPath, '/'); + $rel = preg_replace('~^document/?~', '', $rel); + $filetype = (string) ($e->filetype ?? $e->file_type ?? ''); + if ('folder' === $filetype) { + $rel = rtrim($rel, '/').'/'; + } + if ('' !== $rel) { + $label = $rel; + } + } + } if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) { $label = $idKey; } @@ -886,6 +1082,7 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array $res = \is_array($course->resources ?? null) ? $course->resources : []; + // Buckets (accept legacy casings / aliases) $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? []; $forumRaw = $res['forum'] ?? $res['Forum'] ?? []; $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []); @@ -898,11 +1095,47 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array 'posts' => \is_array($postRaw) ? \count($postRaw) : 0, ]); - $cats = []; - $forums = []; - $topics = []; - $postCountByTopic = []; + // Classifiers (defensive) + $isForum = function (object $o): bool { + $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o; + if (isset($e->forum_title) && \is_string($e->forum_title)) { + return true; + } + if (isset($e->default_view) || isset($e->allow_anonymous)) { + return true; + } + if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) { + return true; + } + + return false; + }; + $isTopic = function (object $o): bool { + $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o; + if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) { + return true; + } + if (isset($e->forum_id) && !isset($e->forum_title)) { + return true; + } + + return false; + }; + $getForumCategoryId = function (object $forum): int { + $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum; + $cid = (int) ($e->forum_category ?? 0); + if ($cid <= 0) { + $cid = (int) ($e->forum_category_id ?? 0); + } + if ($cid <= 0) { + $cid = (int) ($e->category_id ?? 0); + } + return $cid; + }; + + // Categories + $cats = []; foreach ($catRaw as $id => $obj) { $id = (int) $id; if ($id <= 0 || !\is_object($obj)) { @@ -917,23 +1150,53 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array 'children' => [], ]; } + $uncatKey = -9999; + if (!isset($cats[$uncatKey])) { + $cats[$uncatKey] = [ + 'id' => $uncatKey, + 'type' => 'forum_category', + 'label' => 'Uncategorized', + 'selectable' => false, + 'children' => [], + '_virtual' => true, + ]; + } + // Forums + $forums = []; foreach ($forumRaw as $id => $obj) { $id = (int) $id; if ($id <= 0 || !\is_object($obj)) { continue; } + if (!$isForum($obj)) { + $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bag', ['id' => $id]); + + continue; + } $forums[$id] = $this->objectEntity($obj); } + // Topics + post counts + $topics = []; + $postCountByTopic = []; foreach ($topicRaw as $id => $obj) { $id = (int) $id; if ($id <= 0 || !\is_object($obj)) { continue; } + if ($isForum($obj) && !$isTopic($obj)) { + $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bag; skipping', ['id' => $id]); + + continue; + } + if (!$isTopic($obj)) { + $this->logDebug('[buildForumTreeForVue] skipped non-topic in topic bag', ['id' => $id]); + + continue; + } $topics[$id] = $this->objectEntity($obj); } - foreach ($postRaw as $id => $obj) { $id = (int) $id; if ($id <= 0 || !\is_object($obj)) { @@ -946,24 +1209,15 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array } } - $uncatKey = -9999; - if (!isset($cats[$uncatKey])) { - $cats[$uncatKey] = [ - 'id' => $uncatKey, - 'type' => 'forum_category', - 'label' => 'Uncategorized', - 'selectable' => false, - 'children' => [], - '_virtual' => true, - ]; - } - + // Forums → attach topics + // Forums → attach topics foreach ($forums as $fid => $f) { - $catId = (int) ($f->forum_category ?? 0); + $catId = $getForumCategoryId($f); if (!isset($cats[$catId])) { $catId = $uncatKey; } + // Build the forum node first (children will be appended below) $forumNode = [ 'id' => $fid, 'type' => 'forum', @@ -971,6 +1225,9 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array 'extra' => $this->buildExtra('forum', $f) ?: new stdClass(), 'selectable' => true, 'children' => [], + // UI hints (do not affect structure) + 'has_children' => false, // will become true if a topic is attached + 'ui_depth' => 2, // category=1, forum=2, topic=3 (purely informational) ]; foreach ($topics as $tid => $t) { @@ -1003,16 +1260,20 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array 'label' => $topicLabel, 'extra' => new stdClass(), 'selectable' => true, + 'ui_depth' => 3, ]; } + // sort topics (if any) and mark has_children for UI if ($forumNode['children']) { - usort($forumNode['children'], static fn ($a, $b) => strcasecmp($a['label'], $b['label'])); + usort($forumNode['children'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); + $forumNode['has_children'] = true; // <- tell UI to show a reserved toggle space } $cats[$catId]['children'][] = $forumNode; } + // Drop empty virtual category and sort forums per category $catNodes = array_values(array_filter($cats, static function ($c) { if (!empty($c['_virtual']) && empty($c['children'])) { return false; @@ -1021,13 +1282,52 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array return true; })); - foreach ($catNodes as &$c) { - if (!empty($c['children'])) { - usort($c['children'], static fn ($a, $b) => strcasecmp($a['label'], $b['label'])); + // --------- FLATTEN STRAY FORUMS (defensive) ---------- + foreach ($catNodes as &$cat) { + if (empty($cat['children'])) { + continue; + } + + $lift = []; // forums to lift to category level + foreach ($cat['children'] as $idx => &$forumNode) { + if (($forumNode['type'] ?? '') !== 'forum') { + continue; + } + if (empty($forumNode['children'])) { + continue; + } + + // scan children and lift any forum wrongly nested inside + $keepChildren = []; + foreach ($forumNode['children'] as $child) { + if (($child['type'] ?? '') === 'forum') { + // move this stray forum up to category level + $lift[] = $child; + $this->logDebug('[buildForumTreeForVue] flatten: lifted stray forum from inside another forum', [ + 'parent_forum_id' => $forumNode['id'] ?? null, + 'lifted_forum_id' => $child['id'] ?? null, + 'cat_id' => $cat['id'] ?? null, + ]); + } else { + $keepChildren[] = $child; // keep real topics + } + } + $forumNode['children'] = $keepChildren; + } + unset($forumNode); + + // Append lifted forums as siblings (top-level under the category) + if ($lift) { + foreach ($lift as $n) { + $cat['children'][] = $n; + } } + + // sort forums at category level + usort($cat['children'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); } - unset($c); - usort($catNodes, static fn ($a, $b) => strcasecmp($a['label'], $b['label'])); + unset($cat); + // --------- /FLATTEN STRAY FORUMS ---------- $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]); @@ -1095,6 +1395,7 @@ private function getSkipTypeKeys(): array 'session_course' => true, 'scorm' => true, 'asset' => true, + 'link_category' => true, ]; } @@ -1169,13 +1470,26 @@ private function resolveItemLabel(string $type, object $obj, int $fallbackId): s switch ($type) { case 'document': - if (!empty($obj->title)) { - return (string) $obj->title; + // 1) ruta cruda tal como viene del backup/DB + $raw = (string) ($entity->path ?? $obj->path ?? ''); + if ('' !== $raw) { + // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup + $rel = ltrim($raw, '/'); + $rel = preg_replace('~^document/?~', '', $rel); + + // 3) carpeta ⇒ que termine con "/" + $fileType = (string) ($entity->file_type ?? $obj->file_type ?? ''); + if ('folder' === $fileType) { + $rel = rtrim($rel, '/').'/'; + } + + // 4) si la ruta quedó vacía, usa basename como último recurso + return '' !== $rel ? $rel : basename($raw); } - if (!empty($obj->path)) { - $base = basename((string) $obj->path); - return '' !== $base ? $base : (string) $obj->path; + // fallback: título o nombre de archivo + if (!empty($obj->title)) { + return (string) $obj->title; } break; @@ -1651,6 +1965,138 @@ private function filterLegacyCourseBySelection(object $course, array $selected): } } + // --- Añadir carpetas padre de los documentos seleccionados --- + // (para preservar estructura aunque el usuario no marque las carpetas) + $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']); + if ($docKey && !empty($keep[$docKey])) { + $docBucket = $getBucket($orig, $docKey); + + // Indexar carpetas por su ruta relativa a "document/" + $foldersByRel = []; + foreach ($docBucket as $fid => $res) { + $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; + + // Detectar folder (por file_type o por path con "/") + $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); + $isFolder = ('folder' === $ftRaw); + if (!$isFolder) { + $pTest = (string) ($e->path ?? ''); + if ('' !== $pTest) { + $isFolder = ('/' === substr($pTest, -1)); // ej: "document/folder-001/" + } + } + if (!$isFolder) { + continue; + } + + $p = (string) ($e->path ?? ''); + if ('' === $p) { + continue; + } + + // Relativo a "document/…" + $frel = '/'.ltrim(substr($p, 8), '/'); + $frel = rtrim($frel, '/').'/'; + if ('//' !== $frel) { + $foldersByRel[$frel] = $fid; + } + } + + // Determinar carpetas necesarias para cada archivo ya seleccionado + $needFolderIds = []; + foreach ($keep[$docKey] as $id => $res) { + $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; + + // Si es carpeta ya está incluida + $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); + $isFolder = ('folder' === $ftRaw) || ('/' === substr((string) ($e->path ?? ''), -1)); + if ($isFolder) { + continue; + } + + $p = (string) ($e->path ?? ''); + if ('' === $p) { + continue; + } + + // "/subdir/…" relativo a "document/" + $rel = '/'.ltrim(substr($p, 8), '/'); + $dir = rtrim(\dirname($rel), '/'); + if ('' === $dir) { + continue; + } // archivo en raíz + + // Subir por todos los ancestros y marcarlos si existen en el bucket + $acc = ''; + foreach (array_filter(explode('/', $dir)) as $seg) { + $acc .= '/'.$seg; + $accKey = rtrim($acc, '/').'/'; + if (isset($foldersByRel[$accKey])) { + $needFolderIds[$foldersByRel[$accKey]] = true; + } + } + } + + if (!empty($needFolderIds)) { + $added = array_intersect_key($docBucket, $needFolderIds); + $keep[$docKey] += $added; + + $this->logDebug('[filterSelection] added parent folders for selected documents', [ + 'doc_key' => $docKey, + 'added_folders' => \count($added), + ]); + } + } + + // --- Links → categorías usadas --- + $lnkKey = $this->firstExistingKey( + $orig, + ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : ''] + ); + + if ($lnkKey && !empty($keep[$lnkKey])) { + // IDs de categorías realmente usadas por los links seleccionados + $catIdsUsed = []; + foreach ($keep[$lnkKey] as $lid => $lWrap) { + $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap; + $cid = (int) ($L->category_id ?? 0); + if ($cid > 0) { + $catIdsUsed[(string) $cid] = true; + } + } + + // Busca el bucket original tal cual venga en el backup + $catKey = $this->firstExistingKey( + $orig, + ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : ''] + ); + + if ($catKey && !empty($catIdsUsed)) { + $catBucket = $getBucket($orig, $catKey); + if (!empty($catBucket)) { + // Subconjunto de categorías realmente referenciadas + $subset = array_intersect_key($catBucket, $catIdsUsed); + + // 1) Conserva el nombre ORIGINAL del bucket (sin renombrar) + $keep[$catKey] = $subset; + + // 2) Además, crea un espejo bajo 'link_category' por compatibilidad + // (esto evita problemas si el restorer espera esta clave) + $keep['link_category'] = $subset; + + $this->logDebug('[filterSelection] pulled link categories for selected links', [ + 'link_key' => $lnkKey, + 'category_key' => $catKey, + 'links_kept' => \count($keep[$lnkKey]), + 'cats_kept' => \count($subset), + 'mirrored_to' => 'link_category', + ]); + } + } else { + $this->logDebug('[filterSelection] link category bucket not found in backup'); + } + } + $course->resources = array_filter($keep); $this->logDebug('[filterSelection] non-forum flow end', [ 'kept_types' => array_keys($course->resources), @@ -1785,4 +2231,168 @@ private function snapshotForumCounts(object $course): array 'post' => $get('post', 'forum_post'), ]; } + + /** + * Builds the selection map [type => [id => true]] from high-level types. + * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so + * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.). + * + * @param object $course Legacy Course with already hydrated resources + * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath']) + * + * @return array> + */ + private function buildSelectionFromTypes(object $course, array $selectedTypes): array + { + $selectedTypes = array_map( + fn ($t) => $this->normalizeTypeKey((string) $t), + $selectedTypes + ); + + $res = \is_array($course->resources ?? null) ? $course->resources : []; + + $coreDeps = [ + 'document', 'link', 'quiz', 'work', 'survey', + 'Forum_Category', 'forum', 'thread', 'post', + 'exercise_question', 'survey_question', 'link_category', + ]; + + $presentKeys = array_fill_keys(array_map( + fn ($k) => $this->normalizeTypeKey((string) $k), + array_keys($res) + ), true); + + $out = []; + + $addBucket = function (string $typeKey) use (&$out, $res): void { + if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) { + return; + } + $ids = []; + foreach ($res[$typeKey] as $id => $_) { + $ids[(string) $id] = true; + } + if ($ids) { + $out[$typeKey] = $ids; + } + }; + + foreach ($selectedTypes as $t) { + $addBucket($t); + + if ('learnpath' === $t) { + foreach ($coreDeps as $depRaw) { + $dep = $this->normalizeTypeKey($depRaw); + if (isset($presentKeys[$dep])) { + $addBucket($dep); + } + } + } + } + + $this->logDebug('[buildSelectionFromTypes] built', [ + 'selectedTypes' => $selectedTypes, + 'kept_types' => array_keys($out), + ]); + + return $out; + } + + /** + * Build link tree (Category → Link). Categories are not selectable; links are. + */ + private function buildLinkTreeForVue(object $course, string $groupTitle): array + { + $this->logDebug('[buildLinkTreeForVue] start'); + + $res = \is_array($course->resources ?? null) ? $course->resources : []; + + // Buckets from backup (accept both legacy casings) + $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? []; + $linkRaw = $res['link'] ?? $res['Link'] ?? []; + + $this->logDebug('[buildLinkTreeForVue] raw counts', [ + 'categories' => \is_array($catRaw) ? \count($catRaw) : 0, + 'links' => \is_array($linkRaw) ? \count($linkRaw) : 0, + ]); + + // Map of categories + $cats = []; + foreach ($catRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + $e = $this->objectEntity($obj); + $label = $this->resolveItemLabel('link_category', $e, $id); + + $cats[$id] = [ + 'id' => $id, + 'type' => 'link_category', + 'label' => '' !== $label ? $label : ('Category #'.$id), + 'selectable' => false, + 'children' => [], + ]; + } + + // Virtual "Uncategorized" bucket + $uncatKey = -9999; + if (!isset($cats[$uncatKey])) { + $cats[$uncatKey] = [ + 'id' => $uncatKey, + 'type' => 'link_category', + 'label' => 'Uncategorized', + 'selectable' => false, + 'children' => [], + '_virtual' => true, + ]; + } + + // Assign links to categories + foreach ($linkRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + $e = $this->objectEntity($obj); + + $cid = (int) ($e->category_id ?? 0); + if (!isset($cats[$cid])) { + $cid = $uncatKey; + } + + $cats[$cid]['children'][] = [ + 'id' => $id, + 'type' => 'link', + 'label' => $this->resolveItemLabel('link', $e, $id), + 'extra' => $this->buildExtra('link', $e) ?: new stdClass(), + 'selectable' => true, + ]; + } + + // Drop empty virtual category and sort + $catNodes = array_values(array_filter($cats, static function ($c) { + if (!empty($c['_virtual']) && empty($c['children'])) { + return false; + } + + return true; + })); + + foreach ($catNodes as &$c) { + if (!empty($c['children'])) { + usort($c['children'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); + } + } + unset($c); + usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); + + $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]); + + return [ + 'type' => 'link', + 'title' => $groupTitle, + 'items' => $catNodes, + ]; + } } diff --git a/src/CoreBundle/Helpers/ChamiloHelper.php b/src/CoreBundle/Helpers/ChamiloHelper.php index 1c7977b31e5..a6ff974c3f7 100644 --- a/src/CoreBundle/Helpers/ChamiloHelper.php +++ b/src/CoreBundle/Helpers/ChamiloHelper.php @@ -28,8 +28,11 @@ use Throwable; use UserManager; +use const ENT_HTML5; +use const ENT_QUOTES; use const PHP_ROUND_HALF_UP; use const PHP_SAPI; +use const PHP_URL_PATH; class ChamiloHelper { @@ -805,21 +808,6 @@ public static function attachLegacyFileToResource( } } - /** - * Pick the first path that exists (file) from a candidate list. - * Returns the absolute path or null. - */ - public static function firstExistingPath(array $candidates): ?string - { - foreach ($candidates as $p) { - if (self::legacyFileUsable($p)) { - return $p; - } - } - - return null; - } - private static function legacyFileUsable(string $filePath): bool { return is_file($filePath) && is_readable($filePath); @@ -849,123 +837,218 @@ private static function guessResourceRepository(AbstractResource $resource): ?Re } /** - * Attach a legacy file as Asset to an AbstractResource and return public URL. - * Returns ['ok'=>bool, 'asset'=>?Asset, 'url'=>?string, 'error'=>?string]. + * Scan HTML for legacy /courses//document/... references found in a ZIP, + * ensure those files are created as Documents, and return URL maps to rewrite the HTML. + * + * Returns: ['byRel' => [ "document/..." => "public-url" ], + * 'byBase'=> [ "file.ext" => "public-url" ] ] + * + * @param mixed $docRepo + * @param mixed $courseEntity + * @param mixed $session + * @param mixed $group */ - public static function attachLegacyFileWithPublicUrl( - string $filePath, - AbstractResource $ownerResource, - string $fileName = '' + public static function buildUrlMapForHtmlFromPackage( + string $html, + string $courseDir, + string $srcRoot, + array &$folders, + callable $ensureFolder, + $docRepo, + $courseEntity, + $session, + $group, + int $session_id, + int $file_option, + ?callable $dbg = null ): array { - $basename = '' !== $fileName ? $fileName : basename($filePath); + $byRel = []; + $byBase = []; - if (!self::legacyFileUsable($filePath)) { - return ['ok' => false, 'asset' => null, 'url' => null, 'error' => "File not found or unreadable: $basename"]; + $DBG = $dbg ?: static function ($m, $c = []): void { /* no-op */ }; + + // src|href pointing to …/courses//document/... (host optional) + $depRegex = '/(?Psrc|href)\s*=\s*["\'](?P(?:(?Phttps?:)?\/\/[^"\']+)?(?P\/courses\/[^\/]+\/document\/[^"\']+\.[a-z0-9]{1,8}))["\']/i'; + + if (!preg_match_all($depRegex, $html, $mm) || empty($mm['full'])) { + return ['byRel' => $byRel, 'byBase' => $byBase]; } - try { - $assetRepo = Container::getAssetRepository(); + // Normalize a full URL to a "document/..." relative path inside the package + $toRel = static function (string $full) use ($courseDir): string { + $urlPath = parse_url(html_entity_decode($full, ENT_QUOTES | ENT_HTML5), PHP_URL_PATH) ?: $full; + $urlPath = preg_replace('#^/courses/([^/]+)/#i', '/courses/'.$courseDir.'/', $urlPath); + $rel = preg_replace('#^/courses/'.preg_quote($courseDir, '#').'/#i', '', $urlPath) ?: $urlPath; - // Prefer helper if exists - if (method_exists($assetRepo, 'createFromLocalPath')) { - $asset = $assetRepo->createFromLocalPath($filePath, $basename); + return ltrim($rel, '/'); // "document/..." + }; + + foreach ($mm['full'] as $fullUrl) { + $rel = $toRel($fullUrl); // e.g. "document/img.png" + if (!str_starts_with($rel, 'document/')) { + continue; + } // STRICT: only /document/* + if (isset($byRel[$rel])) { + continue; + } + + $basename = basename(parse_url($fullUrl, PHP_URL_PATH) ?: $fullUrl); + $byBase[$basename] = $byBase[$basename] ?? null; + + $parentRelPath = '/'.trim(\dirname('/'.$rel), '/'); // "/document" or "/document/foo" + $depTitle = basename($rel); + $depAbs = rtrim($srcRoot, '/').'/'.$rel; + + // Do NOT create a top-level "/document" root + $parentId = 0; + if ('/document' !== $parentRelPath) { + $parentId = $folders[$parentRelPath] ?? 0; + if (!$parentId) { + $parentId = $ensureFolder($parentRelPath); + $folders[$parentRelPath] = $parentId; + $DBG('helper.ensureFolder', ['parentRelPath' => $parentRelPath, 'parentId' => $parentId]); + } } else { - $mimeType = self::legacyDetectMime($filePath); - $fakeUpload = [ - 'tmp_name' => $filePath, - 'name' => $basename, - 'type' => $mimeType, - 'size' => @filesize($filePath) ?: null, - 'error' => 0, - ]; - $asset = (new Asset())->setTitle($basename)->setCompressed(false); - $assetRepo->createFromRequest($asset, $fakeUpload); + $parentRelPath = '/'; } - if (!method_exists($ownerResource, 'getResourceNode') || null === $ownerResource->getResourceNode()) { - return ['ok' => false, 'asset' => null, 'url' => null, 'error' => 'Owner resource has no ResourceNode']; + if (!is_file($depAbs) || !is_readable($depAbs)) { + $DBG('helper.dep.missing', ['rel' => $rel, 'abs' => $depAbs]); + + continue; } - if (method_exists($assetRepo, 'attachToNode')) { - $assetRepo->attachToNode($asset, $ownerResource->getResourceNode()); - } else { - $repo = self::guessResourceRepository($ownerResource); - if (!$repo || !method_exists($repo, 'attachAssetToResource')) { - return ['ok' => false, 'asset' => $asset, 'url' => null, 'error' => 'No way to attach asset to node']; + // Collision check under parent + $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; + $findExisting = function ($t) use ($docRepo, $parentRes, $courseEntity, $session, $group) { + $e = $docRepo->findCourseResourceByTitle($t, $parentRes->getResourceNode(), $courseEntity, $session, $group); + + return $e && method_exists($e, 'getIid') ? $e->getIid() : null; + }; + + $finalTitle = $depTitle; + $existsIid = $findExisting($finalTitle); + if ($existsIid) { + $FILE_SKIP = \defined('FILE_SKIP') ? FILE_SKIP : 2; + if ($file_option === $FILE_SKIP) { + $existingDoc = $docRepo->find($existsIid); + if ($existingDoc) { + $url = $docRepo->getResourceFileUrl($existingDoc); + if ($url) { + $byRel[$rel] = $url; + $byBase[$basename] = $byBase[$basename] ?: $url; + $DBG('helper.dep.reuse', ['rel' => $rel, 'iid' => $existsIid, 'url' => $url]); + } + } + + continue; + } + // Rename on collision + $pi = pathinfo($depTitle); + $name = $pi['filename'] ?? $depTitle; + $ext2 = isset($pi['extension']) && '' !== $pi['extension'] ? '.'.$pi['extension'] : ''; + $i = 1; + while ($findExisting($finalTitle)) { + $finalTitle = $name.'_'.$i.$ext2; + $i++; } - $repo->attachAssetToResource($ownerResource, $asset); } - // Get a public URL if the repo/asset exposes one - $url = null; - if (method_exists($assetRepo, 'getPublicUrl')) { - $url = $assetRepo->getPublicUrl($asset); - } elseif (method_exists($asset, 'getPublicPath')) { - $url = $asset->getPublicPath(); - } + // Create the non-HTML dependency from the package + try { + $entity = DocumentManager::addDocument( + ['real_id' => $courseEntity->getId(), 'code' => method_exists($courseEntity, 'getCode') ? $courseEntity->getCode() : null], + $parentRelPath, // metadata path (no "/document" root) + 'file', + (int) (@filesize($depAbs) ?: 0), + $finalTitle, + null, + 0, + null, + 0, + (int) $session_id, + 0, + false, + '', + $parentId, + $depAbs + ); + $iid = method_exists($entity, 'getIid') ? $entity->getIid() : 0; + $url = $docRepo->getResourceFileUrl($entity); - return ['ok' => true, 'asset' => $asset, 'url' => $url, 'error' => null]; - } catch (Throwable $e) { - return ['ok' => false, 'asset' => null, 'url' => null, 'error' => $e->getMessage()]; + $DBG('helper.dep.created', ['rel' => $rel, 'iid' => $iid, 'url' => $url]); + + if ($url) { + $byRel[$rel] = $url; + $byBase[$basename] = $byBase[$basename] ?: $url; + } + } catch (Throwable $e) { + $DBG('helper.dep.error', ['rel' => $rel, 'err' => $e->getMessage()]); + } } + + $byBase = array_filter($byBase); + + return ['byRel' => $byRel, 'byBase' => $byBase]; } /** - * Rewrite legacy course URLs (like "document/...") inside $html to Asset URLs. - * Each found local file is attached to $ownerResource as an Asset. + * Rewrite src|href that point to /courses//document/... using: + * - exact match by relative path ("document/...") via $urlMapByRel + * - basename fallback ("file.ext") via $urlMapByBase + * + * Returns: ['html'=>..., 'replaced'=>N, 'misses'=>M] */ - public static function rewriteLegacyCourseUrlsToAssets( + public static function rewriteLegacyCourseUrlsWithMap( string $html, - AbstractResource $ownerResource, - string $backupRoot, - array $extraRoots = [] - ): string { - if ('' === $html) { - return $html; - } + string $courseDir, + array $urlMapByRel, + array $urlMapByBase + ): array { + $replaced = 0; + $misses = 0; - $sources = DocumentManager::get_resources_from_source_html($html, false, TOOL_DOCUMENT, 1); - if (empty($sources)) { - return $html; - } + $pattern = '/(?Psrc|href)\s*=\s*["\'](?P(?:(?Phttps?:)?\/\/[^"\']+)?(?P\/courses\/(?P[^\/]+)\/document\/[^"\']+\.[a-z0-9]{1,8}))["\']/i'; - $roots = array_values(array_unique(array_filter(array_merge([$backupRoot], $extraRoots)))); + $html = preg_replace_callback($pattern, function ($m) use ($courseDir, $urlMapByRel, $urlMapByBase, &$replaced, &$misses) { + $attr = $m['attr']; + $fullUrl = html_entity_decode($m['full'], ENT_QUOTES | ENT_HTML5); + $path = $m['path']; // /courses//document/... + $matchDir = $m['dir']; - foreach ($sources as $s) { - [$realUrl, $scope, $kind] = $s; - if ('local' !== $scope) { - continue; + // Normalize to current course directory + $effectivePath = $path; + if (0 !== strcasecmp($matchDir, $courseDir)) { + $effectivePath = preg_replace('#^/courses/'.preg_quote($matchDir, '#').'/#i', '/courses/'.$courseDir.'/', $path) ?: $path; } - $pos = strpos($realUrl, 'document/'); - if (false === $pos) { - continue; - } - - $relAfterDocument = ltrim(substr($realUrl, $pos + \strlen('document/')), '/'); + // "document/...." + $relInPackage = preg_replace('#^/courses/'.preg_quote($courseDir, '#').'/#i', '', $effectivePath) ?: $effectivePath; + $relInPackage = ltrim($relInPackage, '/'); // document/... - $candidates = []; - foreach ($roots as $root) { - $base = rtrim($root, '/'); - $candidates[] = $base.'/document/'.$relAfterDocument; - $candidates[] = $base.'/courses/document/'.$relAfterDocument; - } + // 1) exact rel match + if (isset($urlMapByRel[$relInPackage])) { + $newUrl = $urlMapByRel[$relInPackage]; + $replaced++; - $filePath = self::firstExistingPath($candidates); - if (!$filePath) { - continue; + return $attr.'="'.htmlspecialchars($newUrl, ENT_QUOTES | ENT_HTML5).'"'; } - $attached = self::attachLegacyFileWithPublicUrl($filePath, $ownerResource, basename($filePath)); - if (!$attached['ok'] || empty($attached['url'])) { - error_log("LEGACY_REWRITE: failed for $realUrl (".$attached['error'].')'); + // 2) basename fallback + $base = basename(parse_url($effectivePath, PHP_URL_PATH) ?: $effectivePath); + if (isset($urlMapByBase[$base])) { + $newUrl = $urlMapByBase[$base]; + $replaced++; - continue; + return $attr.'="'.htmlspecialchars($newUrl, ENT_QUOTES | ENT_HTML5).'"'; } - $html = str_replace($realUrl, $attached['url'], $html); - } + // Not found → keep original + $misses++; + + return $m[0]; + }, $html); - return $html; + return ['html' => $html, 'replaced' => $replaced, 'misses' => $misses]; } } diff --git a/src/CourseBundle/Component/CourseCopy/CourseRecycler.php b/src/CourseBundle/Component/CourseCopy/CourseRecycler.php index 2dfffe83dbb..d0ae71e3d96 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRecycler.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRecycler.php @@ -7,6 +7,7 @@ namespace Chamilo\CourseBundle\Component\CourseCopy; use Chamilo\CoreBundle\Entity\AbstractResource; +use Chamilo\CoreBundle\Entity\GradebookCategory; use Chamilo\CourseBundle\Entity\CAnnouncement; use Chamilo\CourseBundle\Entity\CAttendance; use Chamilo\CourseBundle\Entity\CCalendarEvent; @@ -43,6 +44,9 @@ public function recycle(string $type, array $selected): void // If your EM doesn't have wrapInTransaction(), replace by $this->em->transactional(fn() => { ... }) $this->em->wrapInTransaction(function () use ($isFull, $selected) { + + $this->unplugCertificateDocsForCourse(); + // Links & categories $this->recycleGeneric($isFull, CLink::class, $selected['link'] ?? []); $this->recycleGeneric($isFull, CLinkCategory::class, $selected['link_category'] ?? [], autoClean: true); @@ -204,6 +208,33 @@ private function fetchResourcesForCourse(string $entityClass, ?array $ids = null return $qb->getQuery()->getResult(); } + /** + * Force-unlink associations that can trigger cascade-persist on delete. + * We always null GradebookCategory->document before removing the category. + */ + private function preUnlinkBeforeDelete(array $entities): void + { + $changed = false; + + foreach ($entities as $e) { + if ($e instanceof GradebookCategory + && method_exists($e, 'getDocument') + && method_exists($e, 'setDocument') + ) { + if ($e->getDocument() !== null) { + // Prevent "new entity found through relationship" on flush + $e->setDocument(null); + $this->em->persist($e); + $changed = true; + } + } + } + + if ($changed) { + $this->em->flush(); + } + } + /** * Hard-deletes a list of resources. If repository doesn't provide hardDelete(), * falls back to EM->remove() and a final flush (expect proper cascade mappings). @@ -212,19 +243,21 @@ private function hardDeleteMany(string $entityClass, array $resources): void { $repo = $this->em->getRepository($entityClass); + // Unlink problematic associations up front (prevents cascade-persist on flush) + $this->preUnlinkBeforeDelete($resources); + $usedFallback = false; foreach ($resources as $res) { if (method_exists($repo, 'hardDelete')) { - // hardDelete takes care of Resource, ResourceNode, Links and Files (Flysystem) + // Repo handles full hard delete (nodes/links/files) $repo->hardDelete($res); } else { - // Fallback: standard remove. Ensure your mappings cascade what you need. + // Fallback: standard remove (expect proper cascades elsewhere) $this->em->remove($res); $usedFallback = true; } } - // One flush at the end. If hardDelete() already flushed internally, this is harmless. if ($usedFallback) { $this->em->flush(); } @@ -298,6 +331,41 @@ private function clearLpCategoriesForIds(array $catIds): void } } + private function unplugCertificateDocsForCourse(): void + { + // Detach any certificate-type document from gradebook categories of this course + // Reason: avoid "A new entity was found through the relationship ... #document" on flush. + $qb = $this->em->createQueryBuilder() + ->select('c', 'd') + ->from(GradebookCategory::class, 'c') + ->innerJoin('c.course', 'course') + ->leftJoin('c.document', 'd') + ->where('course.id = :cid') + ->andWhere('d IS NOT NULL') + ->andWhere('d.filetype = :ft') + ->setParameter('cid', $this->courseId) + ->setParameter('ft', 'certificate'); + + /** @var GradebookCategory[] $cats */ + $cats = $qb->getQuery()->getResult(); + + $changed = false; + foreach ($cats as $cat) { + $doc = $cat->getDocument(); + if ($doc instanceof CDocument) { + // Prevent transient Document from being cascaded/persisted during delete + $cat->setDocument(null); + $this->em->persist($cat); + $changed = true; + } + } + + if ($changed) { + // Materialize unlink before any deletion happens + $this->em->flush(); + } + } + /** SCORM directory cleanup for ALL LPs (hook your storage service here if needed) */ private function cleanupScormDirsForAllLp(): void { diff --git a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php index 6e8a71338f2..835a121bca2 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php @@ -1,18 +1,23 @@ debug. + * First teacher (owner) used for forums/posts. */ - private function dlog(string $message, array $context = []): void - { - if (!$this->debug) { - return; - } - $ctx = ''; - if (!empty($context)) { - try { - $ctx = ' ' . json_encode( - $context, - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR - ); - } catch (\Throwable $e) { - $ctx = ' [context_json_failed: '.$e->getMessage().']'; - } - } - error_log('COURSE_DEBUG: '.$message.$ctx); - } + private $first_teacher_id = 0; + + private array $htmlFoldersByCourseDir = []; /** - * Public setter for the debug flag. + * @var array */ - public function setDebug(?bool $on = true): void - { - $this->debug = (bool) $on; - $this->dlog('Debug flag changed', ['debug' => $this->debug]); - } + private array $resources_all_snapshot = []; /** - * CourseRestorer constructor. - * * @param Course $course */ public function __construct($course) { // Read env constant/course hint if present - if (defined('COURSE_RESTORER_DEBUG')) { - $this->debug = (bool) constant('COURSE_RESTORER_DEBUG'); + if (\defined('COURSE_RESTORER_DEBUG')) { + $this->debug = (bool) \constant('COURSE_RESTORER_DEBUG'); } $this->course = $course; @@ -193,8 +199,8 @@ public function __construct($course) $this->dlog('Ctor: initial course info', [ 'course_code' => $this->course->code ?? null, - 'origin_id' => $this->course_origin_id, - 'has_resources' => is_array($this->course->resources ?? null), + 'origin_id' => $this->course_origin_id, + 'has_resources' => \is_array($this->course->resources ?? null), 'resource_keys' => array_keys((array) ($this->course->resources ?? [])), ]); } @@ -204,7 +210,7 @@ public function __construct($course) * * @param int $option FILE_SKIP, FILE_RENAME or FILE_OVERWRITE */ - public function set_file_option($option = FILE_OVERWRITE) + public function set_file_option($option = FILE_OVERWRITE): void { $this->file_option = $option; $this->dlog('File option set', ['file_option' => $this->file_option]); @@ -213,7 +219,7 @@ public function set_file_option($option = FILE_OVERWRITE) /** * @param bool $status */ - public function set_add_text_in_items($status) + public function set_add_text_in_items($status): void { $this->add_text_in_items = $status; } @@ -221,70 +227,18 @@ public function set_add_text_in_items($status) /** * @param array $array */ - public function set_tool_copy_settings($array) + public function set_tool_copy_settings($array): void { $this->tool_copy_settings = $array; } - /** Normalize forum keys so internal bags are always available. */ - private function normalizeForumKeys(): void - { - if (!is_array($this->course->resources ?? null)) { - $this->course->resources = []; - return; - } - $r = $this->course->resources; - - // Categories - if (!isset($r['Forum_Category']) && isset($r['forum_category'])) { - $r['Forum_Category'] = $r['forum_category']; - } - - // Forums - if (!isset($r['forum']) && isset($r['Forum'])) { - $r['forum'] = $r['Forum']; - } - - // Topics - if (!isset($r['thread']) && isset($r['forum_topic'])) { - $r['thread'] = $r['forum_topic']; - } elseif (!isset($r['thread']) && isset($r['Forum_Thread'])) { - $r['thread'] = $r['Forum_Thread']; - } - - // Posts - if (!isset($r['post']) && isset($r['forum_post'])) { - $r['post'] = $r['forum_post']; - } elseif (!isset($r['post']) && isset($r['Forum_Post'])) { - $r['post'] = $r['Forum_Post']; - } - - $this->course->resources = $r; - $this->dlog('Forum keys normalized', [ - 'has_Forum_Category' => isset($r['Forum_Category']), - 'forum_count' => isset($r['forum']) && is_array($r['forum']) ? count($r['forum']) : 0, - 'thread_count' => isset($r['thread']) && is_array($r['thread']) ? count($r['thread']) : 0, - 'post_count' => isset($r['post']) && is_array($r['post']) ? count($r['post']) : 0, - ]); - } - - private function resetDoctrineIfClosed(): void - { - try { - $em = \Database::getManager(); - if (!$em->isOpen()) { - $registry = Container::$container->get('doctrine'); - $registry->resetManager(); - } else { - $em->clear(); - } - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: resetDoctrineIfClosed failed: '.$e->getMessage()); - } - } - /** * Entry point. + * + * @param mixed $destination_course_code + * @param mixed $session_id + * @param mixed $update_course_settings + * @param mixed $respect_base_content */ public function restore( $destination_course_code = '', @@ -293,35 +247,41 @@ public function restore( $respect_base_content = false ) { $this->dlog('Restore() called', [ - 'destination_code' => $destination_course_code, - 'session_id' => (int) $session_id, + 'destination_code' => $destination_course_code, + 'session_id' => (int) $session_id, 'update_course_settings' => (bool) $update_course_settings, 'respect_base_content' => (bool) $respect_base_content, ]); // Resolve destination course - $course_info = $destination_course_code === '' + $course_info = '' === $destination_course_code ? api_get_course_info() : api_get_course_info($destination_course_code); if (empty($course_info) || empty($course_info['real_id'])) { $this->dlog('Destination course not resolved or missing real_id', ['course_info' => $course_info]); + return false; } - $this->destination_course_info = $course_info; - $this->destination_course_id = (int) $course_info['real_id']; + $this->destination_course_info = $course_info; + $this->destination_course_id = (int) $course_info['real_id']; $this->destination_course_entity = api_get_course_entity($this->destination_course_id); // Resolve teacher for forum/thread/post ownership $this->first_teacher_id = api_get_user_id(); $teacher_list = CourseManager::get_teacher_list_from_course_code($course_info['code']); if (!empty($teacher_list)) { - foreach ($teacher_list as $t) { $this->first_teacher_id = (int) $t['user_id']; break; } + foreach ($teacher_list as $t) { + $this->first_teacher_id = (int) $t['user_id']; + + break; + } } if (empty($this->course)) { $this->dlog('No source course found'); + return false; } @@ -330,7 +290,9 @@ public function restore( $sample_text = $this->course->get_sample_text()."\n"; $lines = explode("\n", $sample_text); foreach ($lines as $k => $line) { - if (api_is_valid_ascii($line)) { unset($lines[$k]); } + if (api_is_valid_ascii($line)) { + unset($lines[$k]); + } } $sample_text = implode("\n", $lines); $this->course->encoding = api_detect_encoding($sample_text, $course_info['language']); @@ -340,7 +302,7 @@ public function restore( // Normalize forum bags $this->normalizeForumKeys(); - + $this->ensureDepsBagsFromSnapshot(); // Dump a compact view of the resource bags before restoring $this->debug_course_resources_simple(null); @@ -349,9 +311,10 @@ public function restore( $fn = 'restore_'.$tool; if (method_exists($this, $fn)) { $this->dlog('Starting tool restore', ['tool' => $tool]); + try { $this->{$fn}($session_id, $respect_base_content, $destination_course_code); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->dlog('Tool restore failed with exception', [ 'tool' => $tool, 'error' => $e->getMessage(), @@ -386,7 +349,7 @@ public function restore_course_settings(string $destination_course_code = ''): v $courseEntity = null; - if ($destination_course_code !== '') { + if ('' !== $destination_course_code) { $courseEntity = Container::getCourseRepository()->findOneByCode($destination_course_code); } else { if (!empty($this->destination_course_id)) { @@ -401,6 +364,7 @@ public function restore_course_settings(string $destination_course_code = ''): v if (!$courseEntity) { $this->dlog('No destination course entity found, skipping settings restore'); + return; } @@ -409,13 +373,13 @@ public function restore_course_settings(string $destination_course_code = ''): v if (!empty($src['language'])) { $courseEntity->setCourseLanguage((string) $src['language']); } - if (isset($src['visibility']) && $src['visibility'] !== '') { + if (isset($src['visibility']) && '' !== $src['visibility']) { $courseEntity->setVisibility((int) $src['visibility']); } - if (array_key_exists('department_name', $src)) { + if (\array_key_exists('department_name', $src)) { $courseEntity->setDepartmentName((string) $src['department_name']); } - if (array_key_exists('department_url', $src)) { + if (\array_key_exists('department_url', $src)) { $courseEntity->setDepartmentUrl((string) $src['department_url']); } if (!empty($src['category_id'])) { @@ -425,10 +389,10 @@ public function restore_course_settings(string $destination_course_code = ''): v $courseEntity->setCategories(new ArrayCollection([$cat])); } } - if (array_key_exists('subscribe_allowed', $src)) { + if (\array_key_exists('subscribe_allowed', $src)) { $courseEntity->setSubscribe((bool) $src['subscribe_allowed']); } - if (array_key_exists('unsubscribe', $src)) { + if (\array_key_exists('unsubscribe', $src)) { $courseEntity->setUnsubscribe((bool) $src['unsubscribe']); } @@ -439,63 +403,145 @@ public function restore_course_settings(string $destination_course_code = ''): v $this->dlog('Course settings restored'); } - private function projectUploadBase(): string - { - /** @var KernelInterface $kernel */ - $kernel = Container::$container->get('kernel'); - return rtrim($kernel->getProjectDir(), '/').'/var/upload/resource'; - } - - private function resourceFileAbsPathFromDocument(CDocument $doc): ?string - { - $node = $doc->getResourceNode(); - if (!$node) return null; - - $file = $node->getFirstResourceFile(); - if (!$file) return null; - - /** @var ResourceNodeRepository $rnRepo */ - $rnRepo = Container::$container->get(ResourceNodeRepository::class); - $rel = $rnRepo->getFilename($file); - if (!$rel) return null; - - $abs = $this->projectUploadBase().$rel; - return is_readable($abs) ? $abs : null; - } - /** * Restore documents. + * + * @param mixed $session_id + * @param mixed $respect_base_content + * @param mixed $destination_course_code */ - public function restore_documents($session_id = 0, $respect_base_content = false, $destination_course_code = '') + public function restore_documents($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void { if (!$this->course->has_resources(RESOURCE_DOCUMENT)) { $this->dlog('restore_documents: no document resources'); + return; } - $courseInfo = $this->destination_course_info; - $docRepo = Container::getDocumentRepository(); + $courseInfo = $this->destination_course_info; + $docRepo = Container::getDocumentRepository(); $courseEntity = api_get_course_entity($courseInfo['real_id']); - $session = api_get_session_entity((int)$session_id); - $group = api_get_group_entity(0); + $session = api_get_session_entity((int) $session_id); + $group = api_get_group_entity(0); + // copyMode=false => import from backup_path (package) $copyMode = empty($this->course->backup_path); - $srcRoot = $copyMode ? null : rtrim((string)$this->course->backup_path, '/').'/'; + $srcRoot = $copyMode ? null : rtrim((string) $this->course->backup_path, '/').'/'; + $courseDir = $courseInfo['directory'] ?? $courseInfo['code'] ?? ''; $this->dlog('restore_documents: begin', [ - 'files' => count($this->course->resources[RESOURCE_DOCUMENT] ?? []), + 'files' => \count($this->course->resources[RESOURCE_DOCUMENT] ?? []), 'session' => (int) $session_id, - 'mode' => $copyMode ? 'copy' : 'import', + 'mode' => $copyMode ? 'copy' : 'import', 'srcRoot' => $srcRoot, ]); - // 1) folders + $DBG = function (string $msg, array $ctx = []): void { + error_log('[RESTORE:HTMLURL] '.$msg.(empty($ctx) ? '' : ' '.json_encode($ctx))); + }; + + // Create folder chain under Documents (skipping "document" as root) and return destination parent iid + $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $session_id, $DBG) { + $rel = '/'.ltrim($relPath, '/'); + if ('/' === $rel || '' === $rel) { + return 0; + } + + $parts = array_values(array_filter(explode('/', trim($rel, '/')))); + // skip root "document" + $start = 0; + if (isset($parts[0]) && 'document' === $parts[0]) { + $start = 1; + } + + $accum = ''; + $parentId = 0; + for ($i = $start; $i < \count($parts); $i++) { + $seg = $parts[$i]; + $accum = $accum.'/'.$seg; + + $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; + $title = $seg; + + $existing = $docRepo->findCourseResourceByTitle( + $title, + $parentRes->getResourceNode(), + $courseEntity, + api_get_session_entity((int) $session_id), + api_get_group_entity(0) + ); + + if ($existing) { + $parentId = method_exists($existing, 'getIid') ? $existing->getIid() : 0; + + continue; + } + + $entity = DocumentManager::addDocument( + ['real_id' => $courseInfo['real_id'], 'code' => $courseInfo['code']], + $accum, + 'folder', + 0, + $title, + null, + 0, + null, + 0, + (int) $session_id, + 0, + false, + '', + $parentId, + '' + ); + $parentId = method_exists($entity, 'getIid') ? $entity->getIid() : 0; + + $DBG('ensureFolder:create', ['accum' => $accum, 'iid' => $parentId]); + } + + return $parentId; + }; + + // Robust HTML detection + $isHtmlFile = function (string $filePath, string $nameGuess): bool { + $ext1 = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $ext2 = strtolower(pathinfo($nameGuess, PATHINFO_EXTENSION)); + if (\in_array($ext1, ['html', 'htm'], true) || \in_array($ext2, ['html', 'htm'], true)) { + return true; + } + $peek = (string) @file_get_contents($filePath, false, null, 0, 2048); + if ('' === $peek) { + return false; + } + $s = strtolower($peek); + if (str_contains($s, 'course->resources[RESOURCE_DOCUMENT] as $k => $item) { - if ($item->file_type !== FOLDER) { continue; } + if (FOLDER !== $item->file_type) { + continue; + } - $rel = '/'.ltrim(substr($item->path, 8), '/'); - if ($rel === '/') { continue; } + $rel = '/'.ltrim(substr($item->path, 8), '/'); // remove "document" prefix + if ('/' === $rel) { + continue; + } $parts = array_values(array_filter(explode('/', $rel))); $accum = ''; @@ -503,135 +549,237 @@ public function restore_documents($session_id = 0, $respect_base_content = false foreach ($parts as $i => $seg) { $accum .= '/'.$seg; - if (isset($folders[$accum])) { $parentId = $folders[$accum]; continue; } + if (isset($folders[$accum])) { + $parentId = $folders[$accum]; + + continue; + } $parentResource = $parentId ? $docRepo->find($parentId) : $courseEntity; - $title = ($i === count($parts)-1) ? ($item->title ?: $seg) : $seg; + $title = ($i === \count($parts) - 1) ? ($item->title ?: $seg) : $seg; $existing = $docRepo->findCourseResourceByTitle( - $title, $parentResource->getResourceNode(), $courseEntity, $session, $group + $title, + $parentResource->getResourceNode(), + $courseEntity, + $session, + $group ); if ($existing) { - $iid = method_exists($existing,'getIid') ? $existing->getIid() : 0; + $iid = method_exists($existing, 'getIid') ? $existing->getIid() : 0; $this->dlog('restore_documents: reuse folder', ['title' => $title, 'iid' => $iid]); } else { $entity = DocumentManager::addDocument( - ['real_id'=>$courseInfo['real_id'],'code'=>$courseInfo['code']], - $accum, 'folder', 0, $title, null, 0, null, 0, (int)$session_id, 0, false, '', $parentId, '' + ['real_id' => $courseInfo['real_id'], 'code' => $courseInfo['code']], + $accum, + 'folder', + 0, + $title, + null, + 0, + null, + 0, + (int) $session_id, + 0, + false, + '', + $parentId, + '' ); - $iid = method_exists($entity,'getIid') ? $entity->getIid() : 0; + $iid = method_exists($entity, 'getIid') ? $entity->getIid() : 0; $this->dlog('restore_documents: created folder', ['title' => $title, 'iid' => $iid]); } $folders[$accum] = $iid; - if ($i === count($parts)-1) { + if ($i === \count($parts) - 1) { $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid; } $parentId = $iid; } } - // 2) files + // GLOBAL PRE-SCAN with helper: build URL maps for all HTML dependencies (non-HTML files) + $urlMapByRel = []; + $urlMapByBase = []; + + foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) { + if (DOCUMENT !== $item->file_type) { + continue; + } + if ($copyMode) { + continue; + } // only when importing from package + + $rawTitle = $item->title ?: basename((string) $item->path); + $srcPath = $srcRoot.$item->path; + if (!is_file($srcPath) || !is_readable($srcPath)) { + continue; + } + + // Only HTML + if (!$isHtmlFile($srcPath, $rawTitle)) { + continue; + } + + $html = (string) @file_get_contents($srcPath); + if ('' === $html) { + continue; + } + + $maps = ChamiloHelper::buildUrlMapForHtmlFromPackage( + $html, + $courseDir, + $srcRoot, + $folders, + $ensureFolder, + $docRepo, + $courseEntity, + $session, + $group, + (int) $session_id, + (int) $this->file_option, + $DBG + ); + + // Merge without overwriting previously resolved keys + foreach ($maps['byRel'] as $kRel => $vUrl) { + if (!isset($urlMapByRel[$kRel])) { + $urlMapByRel[$kRel] = $vUrl; + } + } + foreach ($maps['byBase'] as $kBase => $vUrl) { + if (!isset($urlMapByBase[$kBase])) { + $urlMapByBase[$kBase] = $vUrl; + } + } + } + $DBG('global.map.stats', ['byRel' => \count($urlMapByRel), 'byBase' => \count($urlMapByBase)]); + + // Import files from backup (rewrite HTML BEFORE creating the Document) foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) { - if ($item->file_type !== DOCUMENT) { continue; } + if (DOCUMENT !== $item->file_type) { + continue; + } - $srcPath = null; - $rawTitle = $item->title ?: basename((string)$item->path); - $ext = strtolower(pathinfo($rawTitle, PATHINFO_EXTENSION)); - $isHtml = in_array($ext, ['html','htm'], true); + $srcPath = null; + $rawTitle = $item->title ?: basename((string) $item->path); if ($copyMode) { $srcDoc = null; if (!empty($item->source_id)) { - $srcDoc = $docRepo->find((int)$item->source_id); + $srcDoc = $docRepo->find((int) $item->source_id); } if (!$srcDoc) { $this->dlog('restore_documents: source CDocument not found by source_id', ['source_id' => $item->source_id ?? null]); + continue; } $srcPath = $this->resourceFileAbsPathFromDocument($srcDoc); if (!$srcPath) { - $this->dlog('restore_documents: source file not readable from ResourceFile', ['source_id' => (int)$item->source_id]); + $this->dlog('restore_documents: source file not readable from ResourceFile', ['source_id' => (int) $item->source_id]); + continue; } } else { $srcPath = $srcRoot.$item->path; if (!is_file($srcPath) || !is_readable($srcPath)) { $this->dlog('restore_documents: source file not found/readable', ['src' => $srcPath]); + continue; } } - $rel = '/'.ltrim(substr($item->path, 8), '/'); - $parentRel = rtrim(dirname($rel), '/'); - $parentId = $folders[$parentRel] ?? 0; + $isHtml = $isHtmlFile($srcPath, $rawTitle); + + $rel = '/'.ltrim(substr($item->path, 8), '/'); // remove "document" prefix + $parentRel = rtrim(\dirname($rel), '/'); + $parentId = $folders[$parentRel] ?? 0; + if (!$parentId) { + $parentId = $ensureFolder($parentRel); + $folders[$parentRel] = $parentId; + } $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; - $baseTitle = $rawTitle; + $baseTitle = $rawTitle; $finalTitle = $baseTitle; - $findExisting = function($t) use ($docRepo,$parentRes,$courseEntity,$session,$group){ + $findExisting = function ($t) use ($docRepo, $parentRes, $courseEntity, $session, $group) { $e = $docRepo->findCourseResourceByTitle($t, $parentRes->getResourceNode(), $courseEntity, $session, $group); - return $e && method_exists($e,'getIid') ? $e->getIid() : null; + + return $e && method_exists($e, 'getIid') ? $e->getIid() : null; }; $existsIid = $findExisting($finalTitle); if ($existsIid) { $this->dlog('restore_documents: collision', ['title' => $finalTitle, 'policy' => $this->file_option]); - if ($this->file_option === FILE_SKIP) { + if (FILE_SKIP === $this->file_option) { $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $existsIid; + continue; } - $pi = pathinfo($baseTitle); + $pi = pathinfo($baseTitle); $name = $pi['filename'] ?? $baseTitle; - $ext2 = isset($pi['extension']) && $pi['extension'] !== '' ? '.'.$pi['extension'] : ''; - $i=1; - while ($findExisting($finalTitle)) { $finalTitle = $name.'_'.$i.$ext2; $i++; } + $ext2 = isset($pi['extension']) && '' !== $pi['extension'] ? '.'.$pi['extension'] : ''; + $i = 1; + while ($findExisting($finalTitle)) { + $finalTitle = $name.'_'.$i.$ext2; + $i++; + } } - $content = ''; + // Build content/realPath + $content = ''; $realPath = ''; + if ($isHtml) { $raw = @file_get_contents($srcPath) ?: ''; - if (defined('UTF8_CONVERT') && UTF8_CONVERT) { $raw = utf8_encode($raw); } - $content = DocumentManager::replaceUrlWithNewCourseCode( + if (\defined('UTF8_CONVERT') && UTF8_CONVERT) { + $raw = utf8_encode($raw); + } + + // Rewrite using both maps (exact rel + basename fallback) BEFORE addDocument + $DBG('html:rewrite:before', ['title' => $finalTitle, 'byRel' => \count($urlMapByRel), 'byBase' => \count($urlMapByBase)]); + $rew = ChamiloHelper::rewriteLegacyCourseUrlsWithMap( $raw, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] + $courseDir, + $urlMapByRel, + $urlMapByBase ); + $DBG('html:rewrite:after', ['title' => $finalTitle, 'replaced' => $rew['replaced'], 'misses' => $rew['misses']]); + + $content = $rew['html']; } else { $realPath = $srcPath; } try { $entity = DocumentManager::addDocument( - ['real_id'=>$courseInfo['real_id'],'code'=>$courseInfo['code']], + ['real_id' => $courseInfo['real_id'], 'code' => $courseInfo['code']], $rel, 'file', - (int)($item->size ?? 0), + (int) ($item->size ?? 0), $finalTitle, $item->comment ?? '', 0, null, 0, - (int)$session_id, + (int) $session_id, 0, false, $content, $parentId, $realPath ); - $iid = method_exists($entity,'getIid') ? $entity->getIid() : 0; + $iid = method_exists($entity, 'getIid') ? $entity->getIid() : 0; $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid; + $this->dlog('restore_documents: file created', [ 'title' => $finalTitle, - 'iid' => $iid, - 'mode' => $copyMode ? 'copy' : 'import' + 'iid' => $iid, + 'mode' => $copyMode ? 'copy' : 'import', ]); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->dlog('restore_documents: file create failed', ['title' => $finalTitle, 'error' => $e->getMessage()]); } } @@ -640,131 +788,48 @@ public function restore_documents($session_id = 0, $respect_base_content = false } /** - * Compact dump of resources: keys, per-bag counts and one sample (trimmed). + * Restore forum categories in the destination course. + * + * @param mixed $session_id + * @param mixed $respect_base_content + * @param mixed $destination_course_code */ - private function debug_course_resources_simple(?string $focusBag = null, int $maxObjFields = 10): void + public function restore_forum_category($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void { - try { - $resources = is_array($this->course->resources ?? null) ? $this->course->resources : []; + $bag = $this->course->resources['Forum_Category'] + ?? $this->course->resources['forum_category'] + ?? []; - $safe = function ($data): string { - try { - return json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[json_encode_failed]'; - } catch (\Throwable $e) { - return '[json_exception: '.$e->getMessage().']'; - } - }; - $short = function ($v, int $max = 200) { - if (is_string($v)) { - $s = trim($v); - return mb_strlen($s) > $max ? (mb_substr($s, 0, $max).'…('.mb_strlen($s).' chars)') : $s; - } - if (is_numeric($v) || is_bool($v) || $v === null) return $v; - return '['.gettype($v).']'; - }; - $sample = function ($item) use ($short, $maxObjFields) { - $out = [ - 'source_id' => null, - 'destination_id' => null, - 'type' => null, - 'has_obj' => false, - 'obj_fields' => [], - 'has_item_props' => false, - 'extra' => [], - ]; - if (is_object($item) || is_array($item)) { - $arr = (array)$item; - $out['source_id'] = $arr['source_id'] ?? null; - $out['destination_id'] = $arr['destination_id'] ?? null; - $out['type'] = $arr['type'] ?? null; - $out['has_item_props'] = !empty($arr['item_properties']); + if (empty($bag)) { + $this->dlog('restore_forum_category: empty bag'); - $obj = $arr['obj'] ?? null; - if (is_object($obj) || is_array($obj)) { - $out['has_obj'] = true; - $objArr = (array)$obj; - $fields = []; - $i = 0; - foreach ($objArr as $k => $v) { - if ($i++ >= $maxObjFields) { $fields['__notice'] = 'truncated'; break; } - $fields[$k] = $short($v); - } - $out['obj_fields'] = $fields; - } - foreach (['path','title','comment'] as $k) { - if (isset($arr[$k])) $out['extra'][$k] = $short($arr[$k]); - } - } else { - $out['extra']['_type'] = gettype($item); - } - return $out; - }; - - $this->dlog('Resources overview', ['keys' => array_keys($resources)]); - - foreach ($resources as $bagName => $bag) { - if (!is_array($bag)) { - $this->dlog("Bag not an array, skipping", ['bag' => $bagName, 'type' => gettype($bag)]); - continue; - } - $count = count($bag); - $this->dlog('Bag count', ['bag' => $bagName, 'count' => $count]); - - if ($count > 0) { - $firstKey = array_key_first($bag); - $firstVal = $bag[$firstKey]; - $s = $sample($firstVal); - $s['__first_key'] = $firstKey; - $s['__class'] = is_object($firstVal) ? get_class($firstVal) : gettype($firstVal); - $this->dlog('Bag sample', ['bag' => $bagName, 'sample' => $s]); - } - - if ($focusBag !== null && $focusBag === $bagName) { - $preview = []; - $i = 0; - foreach ($bag as $k => $v) { - if ($i++ >= 10) { $preview[] = ['__notice' => 'truncated-after-10-items']; break; } - $preview[] = ['key' => $k, 'sample' => $sample($v)]; - } - $this->dlog('Bag deep preview', ['bag' => $bagName, 'items' => $preview]); - } - } - } catch (\Throwable $e) { - $this->dlog('Failed to dump resources', ['error' => $e->getMessage()]); - } - } - - public function restore_forum_category($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void - { - $bag = $this->course->resources['Forum_Category'] - ?? $this->course->resources['forum_category'] - ?? []; - - if (empty($bag)) { - $this->dlog('restore_forum_category: empty bag'); return; } - $em = Database::getManager(); + $em = Database::getManager(); $catRepo = Container::getForumCategoryRepository(); - $course = api_get_course_entity($this->destination_course_id); - $session = api_get_session_entity((int)$session_id); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int) $session_id); foreach ($bag as $id => $res) { - if (!empty($res->destination_id)) { continue; } + if (!empty($res->destination_id)) { + continue; + } - $obj = is_object($res->obj ?? null) ? $res->obj : (object)[]; - $title = (string)($obj->cat_title ?? $obj->title ?? "Forum category #$id"); - $comment = (string)($obj->cat_comment ?? $obj->description ?? ''); + $obj = \is_object($res->obj ?? null) ? $res->obj : (object) []; + $title = (string) ($obj->cat_title ?? $obj->title ?? "Forum category #$id"); + $comment = (string) ($obj->cat_comment ?? $obj->description ?? ''); + + // Reescritura/creación de dependencias en contenido HTML (document/*) vía helper + $comment = $this->rewriteHtmlForCourse($comment, (int) $session_id, '[forums.cat]'); $existing = $catRepo->findOneBy(['title' => $title, 'resourceNode.parent' => $course->getResourceNode()]); if ($existing) { - $destIid = (int)$existing->getIid(); - if (!isset($this->course->resources['Forum_Category'])) { - $this->course->resources['Forum_Category'] = []; - } + $destIid = (int) $existing->getIid(); + $this->course->resources['Forum_Category'][$id] ??= new stdClass(); $this->course->resources['Forum_Category'][$id]->destination_id = $destIid; $this->dlog('restore_forum_category: reuse existing', ['title' => $title, 'iid' => $destIid]); + continue; } @@ -772,72 +837,80 @@ public function restore_forum_category($session_id = 0, $respect_base_content = ->setTitle($title) ->setCatComment($comment) ->setParent($course) - ->addCourseLink($course, $session); + ->addCourseLink($course, $session) + ; $catRepo->create($cat); $em->flush(); - $this->course->resources['Forum_Category'][$id]->destination_id = (int)$cat->getIid(); - $this->dlog('restore_forum_category: created', ['title' => $title, 'iid' => (int)$cat->getIid()]); + $this->course->resources['Forum_Category'][$id] ??= new stdClass(); + $this->course->resources['Forum_Category'][$id]->destination_id = (int) $cat->getIid(); + $this->dlog('restore_forum_category: created', ['title' => $title, 'iid' => (int) $cat->getIid()]); } - $this->dlog('restore_forum_category: done', ['count' => count($bag)]); + $this->dlog('restore_forum_category: done', ['count' => \count($bag)]); } + /** + * Restore forums and their topics/posts. + */ public function restore_forums(int $sessionId = 0): void { $forumsBag = $this->course->resources['forum'] ?? []; if (empty($forumsBag)) { $this->dlog('restore_forums: empty forums bag'); + return; } - $em = Database::getManager(); - $catRepo = Container::getForumCategoryRepository(); + $em = Database::getManager(); + $catRepo = Container::getForumCategoryRepository(); $forumRepo = Container::getForumRepository(); - $course = api_get_course_entity($this->destination_course_id); + $course = api_get_course_entity($this->destination_course_id); $session = api_get_session_entity($sessionId); - // Build/ensure categories $catBag = $this->course->resources['Forum_Category'] ?? $this->course->resources['forum_category'] ?? []; $catMap = []; if (!empty($catBag)) { foreach ($catBag as $srcCatId => $res) { - if (!empty($res->destination_id)) { - $catMap[(int)$srcCatId] = (int)$res->destination_id; + if ((int) $res->destination_id > 0) { + $catMap[(int) $srcCatId] = (int) $res->destination_id; + continue; } - $obj = is_object($res->obj ?? null) ? $res->obj : (object)[]; - $title = (string)($obj->cat_title ?? $obj->title ?? "Forum category #$srcCatId"); - $comment = (string)($obj->cat_comment ?? $obj->description ?? ''); + $obj = \is_object($res->obj ?? null) ? $res->obj : (object) []; + $title = (string) ($obj->cat_title ?? $obj->title ?? "Forum category #$srcCatId"); + $comment = (string) ($obj->cat_comment ?? $obj->description ?? ''); + + $comment = $this->rewriteHtmlForCourse($comment, (int) $sessionId, '[forums.cat@forums]'); $cat = (new CForumCategory()) ->setTitle($title) ->setCatComment($comment) ->setParent($course) - ->addCourseLink($course, $session); + ->addCourseLink($course, $session) + ; $catRepo->create($cat); $em->flush(); - $destIid = (int)$cat->getIid(); - $catMap[(int)$srcCatId] = $destIid; + $destIid = (int) $cat->getIid(); + $catMap[(int) $srcCatId] = $destIid; - if (!isset($this->course->resources['Forum_Category'])) { - $this->course->resources['Forum_Category'] = []; - } + $this->course->resources['Forum_Category'][$srcCatId] ??= new stdClass(); $this->course->resources['Forum_Category'][$srcCatId]->destination_id = $destIid; - $this->dlog('restore_forums: created category', ['src_id' => (int)$srcCatId, 'iid' => $destIid, 'title' => $title]); + $this->dlog('restore_forums: created category', [ + 'src_id' => (int) $srcCatId, 'iid' => $destIid, 'title' => $title, + ]); } } - // Default category "General" if needed $defaultCategory = null; - $ensureDefault = function() use (&$defaultCategory, $course, $session, $catRepo, $em): CForumCategory { + $ensureDefault = function () use (&$defaultCategory, $course, $session, $catRepo, $em): CForumCategory { if ($defaultCategory instanceof CForumCategory) { return $defaultCategory; } @@ -845,135 +918,155 @@ public function restore_forums(int $sessionId = 0): void ->setTitle('General') ->setCatComment('') ->setParent($course) - ->addCourseLink($course, $session); + ->addCourseLink($course, $session) + ; $catRepo->create($defaultCategory); $em->flush(); + return $defaultCategory; }; - // Create forums and their topics foreach ($forumsBag as $srcForumId => $forumRes) { - if (!is_object($forumRes) || !is_object($forumRes->obj)) { continue; } - $p = (array)$forumRes->obj; + if (!\is_object($forumRes) || !\is_object($forumRes->obj)) { + continue; + } + $p = (array) $forumRes->obj; $dstCategory = null; - $srcCatId = (int)($p['forum_category'] ?? 0); + $srcCatId = (int) ($p['forum_category'] ?? 0); if ($srcCatId > 0 && isset($catMap[$srcCatId])) { $dstCategory = $catRepo->find($catMap[$srcCatId]); } - if (!$dstCategory && count($catMap) === 1) { - $onlyDestIid = (int)reset($catMap); + if (!$dstCategory && 1 === \count($catMap)) { + $onlyDestIid = (int) reset($catMap); $dstCategory = $catRepo->find($onlyDestIid); } if (!$dstCategory) { $dstCategory = $ensureDefault(); } + $forumComment = (string) ($p['forum_comment'] ?? ''); + $forumComment = $this->rewriteHtmlForCourse($forumComment, (int) $sessionId, '[forums.forum]'); + $forum = (new CForum()) ->setTitle($p['forum_title'] ?? ('Forum #'.$srcForumId)) - ->setForumComment((string)($p['forum_comment'] ?? '')) + ->setForumComment($forumComment) ->setForumCategory($dstCategory) - ->setAllowAnonymous((int)($p['allow_anonymous'] ?? 0)) - ->setAllowEdit((int)($p['allow_edit'] ?? 0)) - ->setApprovalDirectPost((string)($p['approval_direct_post'] ?? '0')) - ->setAllowAttachments((int)($p['allow_attachments'] ?? 1)) - ->setAllowNewThreads((int)($p['allow_new_threads'] ?? 1)) + ->setAllowAnonymous((int) ($p['allow_anonymous'] ?? 0)) + ->setAllowEdit((int) ($p['allow_edit'] ?? 0)) + ->setApprovalDirectPost((string) ($p['approval_direct_post'] ?? '0')) + ->setAllowAttachments((int) ($p['allow_attachments'] ?? 1)) + ->setAllowNewThreads((int) ($p['allow_new_threads'] ?? 1)) ->setDefaultView($p['default_view'] ?? 'flat') - ->setForumOfGroup((string)($p['forum_of_group'] ?? 0)) + ->setForumOfGroup((string) ($p['forum_of_group'] ?? 0)) ->setForumGroupPublicPrivate($p['forum_group_public_private'] ?? 'public') - ->setModerated((bool)($p['moderated'] ?? false)) - ->setStartTime(!empty($p['start_time']) && $p['start_time'] !== '0000-00-00 00:00:00' + ->setModerated((bool) ($p['moderated'] ?? false)) + ->setStartTime(!empty($p['start_time']) && '0000-00-00 00:00:00' !== $p['start_time'] ? api_get_utc_datetime($p['start_time'], true, true) : null) - ->setEndTime(!empty($p['end_time']) && $p['end_time'] !== '0000-00-00 00:00:00' + ->setEndTime(!empty($p['end_time']) && '0000-00-00 00:00:00' !== $p['end_time'] ? api_get_utc_datetime($p['end_time'], true, true) : null) ->setParent($dstCategory ?: $course) - ->addCourseLink($course, $session); + ->addCourseLink($course, $session) + ; $forumRepo->create($forum); $em->flush(); - $this->course->resources['forum'][$srcForumId]->destination_id = (int)$forum->getIid(); + $this->course->resources['forum'][$srcForumId] ??= new stdClass(); + $this->course->resources['forum'][$srcForumId]->destination_id = (int) $forum->getIid(); $this->dlog('restore_forums: created forum', [ - 'src_forum_id' => (int)$srcForumId, - 'dst_forum_iid'=> (int)$forum->getIid(), - 'category_iid' => (int)$dstCategory->getIid(), + 'src_forum_id' => (int) $srcForumId, + 'dst_forum_iid' => (int) $forum->getIid(), + 'category_iid' => (int) $dstCategory->getIid(), ]); - // Topics of this forum $topicsBag = $this->course->resources['thread'] ?? []; foreach ($topicsBag as $srcThreadId => $topicRes) { - if (!is_object($topicRes) || !is_object($topicRes->obj)) { continue; } - if ((int)$topicRes->obj->forum_id === (int)$srcForumId) { - $tid = $this->restore_topic((int)$srcThreadId, (int)$forum->getIid(), $sessionId); + if (!\is_object($topicRes) || !\is_object($topicRes->obj)) { + continue; + } + if ((int) $topicRes->obj->forum_id === (int) $srcForumId) { + $tid = $this->restore_topic((int) $srcThreadId, (int) $forum->getIid(), $sessionId); $this->dlog('restore_forums: topic restored', [ - 'src_thread_id' => (int)$srcThreadId, - 'dst_thread_iid'=> (int)($tid ?? 0), - 'dst_forum_iid' => (int)$forum->getIid(), + 'src_thread_id' => (int) $srcThreadId, + 'dst_thread_iid' => (int) ($tid ?? 0), + 'dst_forum_iid' => (int) $forum->getIid(), ]); } } } - $this->dlog('restore_forums: done', ['forums' => count($forumsBag)]); + $this->dlog('restore_forums: done', ['forums' => \count($forumsBag)]); } + /** + * Restore a forum topic (thread). + */ public function restore_topic(int $srcThreadId, int $dstForumId, int $sessionId = 0): ?int { $topicsBag = $this->course->resources['thread'] ?? []; - $topicRes = $topicsBag[$srcThreadId] ?? null; - if (!$topicRes || !is_object($topicRes->obj)) { + $topicRes = $topicsBag[$srcThreadId] ?? null; + if (!$topicRes || !\is_object($topicRes->obj)) { $this->dlog('restore_topic: missing topic object', ['src_thread_id' => $srcThreadId]); + return null; } - $em = Database::getManager(); - $forumRepo = Container::getForumRepository(); + $em = Database::getManager(); + $forumRepo = Container::getForumRepository(); $threadRepo = Container::getForumThreadRepository(); - $postRepo = Container::getForumPostRepository(); + $postRepo = Container::getForumPostRepository(); - $course = api_get_course_entity($this->destination_course_id); - $session = api_get_session_entity((int)$sessionId); - $user = api_get_user_entity($this->first_teacher_id); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int) $sessionId); + $user = api_get_user_entity($this->first_teacher_id); /** @var CForum|null $forum */ $forum = $forumRepo->find($dstForumId); if (!$forum) { $this->dlog('restore_topic: destination forum not found', ['dst_forum_id' => $dstForumId]); + return null; } - $p = (array)$topicRes->obj; + $p = (array) $topicRes->obj; $thread = (new CForumThread()) - ->setTitle((string)($p['thread_title'] ?? "Thread #$srcThreadId")) + ->setTitle((string) ($p['thread_title'] ?? "Thread #$srcThreadId")) ->setForum($forum) ->setUser($user) - ->setThreadDate(new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'))) - ->setThreadSticky((bool)($p['thread_sticky'] ?? false)) - ->setThreadTitleQualify((string)($p['thread_title_qualify'] ?? '')) - ->setThreadQualifyMax((float)($p['thread_qualify_max'] ?? 0)) - ->setThreadWeight((float)($p['thread_weight'] ?? 0)) - ->setThreadPeerQualify((bool)($p['thread_peer_qualify'] ?? false)) + ->setThreadDate(new DateTime(api_get_utc_datetime(), new DateTimeZone('UTC'))) + ->setThreadSticky((bool) ($p['thread_sticky'] ?? false)) + ->setThreadTitleQualify((string) ($p['thread_title_qualify'] ?? '')) + ->setThreadQualifyMax((float) ($p['thread_qualify_max'] ?? 0)) + ->setThreadWeight((float) ($p['thread_weight'] ?? 0)) + ->setThreadPeerQualify((bool) ($p['thread_peer_qualify'] ?? false)) ->setParent($forum) - ->addCourseLink($course, $session); + ->addCourseLink($course, $session) + ; $threadRepo->create($thread); $em->flush(); - $this->course->resources['thread'][$srcThreadId]->destination_id = (int)$thread->getIid(); + $this->course->resources['thread'][$srcThreadId] ??= new stdClass(); + $this->course->resources['thread'][$srcThreadId]->destination_id = (int) $thread->getIid(); $this->dlog('restore_topic: created', [ 'src_thread_id' => $srcThreadId, - 'dst_thread_iid'=> (int)$thread->getIid(), - 'dst_forum_iid' => (int)$forum->getIid(), + 'dst_thread_iid' => (int) $thread->getIid(), + 'dst_forum_iid' => (int) $forum->getIid(), ]); - // Posts - $postsBag = $this->course->resources[ 'post'] ?? []; + $postsBag = $this->course->resources['post'] ?? []; foreach ($postsBag as $srcPostId => $postRes) { - if (!is_object($postRes) || !is_object($postRes->obj)) { continue; } - if ((int)$postRes->obj->thread_id === (int)$srcThreadId) { - $pid = $this->restore_post((int)$srcPostId, (int)$thread->getIid(), (int)$forum->getIid(), $sessionId); - $this->dlog('restore_topic: post restored', ['src_post_id' => (int)$srcPostId, 'dst_post_iid' => (int)($pid ?? 0)]); + if (!\is_object($postRes) || !\is_object($postRes->obj)) { + continue; + } + if ((int) $postRes->obj->thread_id === (int) $srcThreadId) { + $pid = $this->restore_post((int) $srcPostId, (int) $thread->getIid(), (int) $forum->getIid(), $sessionId); + $this->dlog('restore_topic: post restored', [ + 'src_post_id' => (int) $srcPostId, + 'dst_post_iid' => (int) ($pid ?? 0), + ]); } } @@ -984,54 +1077,63 @@ public function restore_topic(int $srcThreadId, int $dstForumId, int $sessionId $em->flush(); } - return (int)$thread->getIid(); + return (int) $thread->getIid(); } + /** + * Restore a forum post. + */ public function restore_post(int $srcPostId, int $dstThreadId, int $dstForumId, int $sessionId = 0): ?int { $postsBag = $this->course->resources['post'] ?? []; - $postRes = $postsBag[$srcPostId] ?? null; - if (!$postRes || !is_object($postRes->obj)) { + $postRes = $postsBag[$srcPostId] ?? null; + if (!$postRes || !\is_object($postRes->obj)) { $this->dlog('restore_post: missing post object', ['src_post_id' => $srcPostId]); + return null; } - $em = Database::getManager(); - $forumRepo = Container::getForumRepository(); + $em = Database::getManager(); + $forumRepo = Container::getForumRepository(); $threadRepo = Container::getForumThreadRepository(); - $postRepo = Container::getForumPostRepository(); + $postRepo = Container::getForumPostRepository(); - $course = api_get_course_entity($this->destination_course_id); - $session = api_get_session_entity((int)$sessionId); - $user = api_get_user_entity($this->first_teacher_id); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int) $sessionId); + $user = api_get_user_entity($this->first_teacher_id); $thread = $threadRepo->find($dstThreadId); - $forum = $forumRepo->find($dstForumId); + $forum = $forumRepo->find($dstForumId); if (!$thread || !$forum) { $this->dlog('restore_post: destination thread/forum not found', [ 'dst_thread_id' => $dstThreadId, - 'dst_forum_id' => $dstForumId, + 'dst_forum_id' => $dstForumId, ]); + return null; } - $p = (array)$postRes->obj; + $p = (array) $postRes->obj; + + $postText = (string) ($p['post_text'] ?? ''); + $postText = $this->rewriteHtmlForCourse($postText, (int) $sessionId, '[forums.post]'); $post = (new CForumPost()) - ->setTitle((string)($p['post_title'] ?? "Post #$srcPostId")) - ->setPostText((string)($p['post_text'] ?? '')) + ->setTitle((string) ($p['post_title'] ?? "Post #$srcPostId")) + ->setPostText($postText) ->setThread($thread) ->setForum($forum) ->setUser($user) - ->setPostDate(new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'))) - ->setPostNotification((bool)($p['post_notification'] ?? false)) + ->setPostDate(new DateTime(api_get_utc_datetime(), new DateTimeZone('UTC'))) + ->setPostNotification((bool) ($p['post_notification'] ?? false)) ->setVisible(true) ->setStatus(CForumPost::STATUS_VALIDATED) ->setParent($thread) - ->addCourseLink($course, $session); + ->addCourseLink($course, $session) + ; if (!empty($p['post_parent_id'])) { - $parentDestId = (int)($postsBag[$p['post_parent_id']]->destination_id ?? 0); + $parentDestId = (int) ($postsBag[$p['post_parent_id']]->destination_id ?? 0); if ($parentDestId > 0) { $parent = $postRepo->find($parentDestId); if ($parent) { @@ -1043,22 +1145,28 @@ public function restore_post(int $srcPostId, int $dstThreadId, int $dstForumId, $postRepo->create($post); $em->flush(); - $this->course->resources['post'][$srcPostId]->destination_id = (int)$post->getIid(); + $this->course->resources['post'][$srcPostId] ??= new stdClass(); + $this->course->resources['post'][$srcPostId]->destination_id = (int) $post->getIid(); $this->dlog('restore_post: created', [ - 'src_post_id' => (int)$srcPostId, - 'dst_post_iid' => (int)$post->getIid(), - 'dst_thread_id' => (int)$thread->getIid(), - 'dst_forum_id' => (int)$forum->getIid(), + 'src_post_id' => (int) $srcPostId, + 'dst_post_iid' => (int) $post->getIid(), + 'dst_thread_id' => (int) $thread->getIid(), + 'dst_forum_id' => (int) $forum->getIid(), ]); - return (int)$post->getIid(); + return (int) $post->getIid(); } - public function restore_link_category($id, $sessionId = 0) + /** + * Restore a link category. + * + * @param mixed $id + * @param mixed $sessionId + */ + public function restore_link_category($id, $sessionId = 0): int { $sessionId = (int) $sessionId; - // "No category" short-circuit (legacy used 0 as 'uncategorized'). if (0 === (int) $id) { $this->dlog('restore_link_category: source category is 0 (no category), returning 0'); @@ -1066,32 +1174,105 @@ public function restore_link_category($id, $sessionId = 0) } $resources = $this->course->resources ?? []; - $srcCat = $resources[RESOURCE_LINKCATEGORY][$id] ?? null; - if (!is_object($srcCat)) { - error_log('COURSE_DEBUG: restore_link_category: source category object not found for id ' . $id); + // Resolve the actual bucket key present in this backup + $candidateKeys = ['link_category', 'Link_Category']; + if (\defined('RESOURCE_LINKCATEGORY') && RESOURCE_LINKCATEGORY) { + $candidateKeys[] = (string) RESOURCE_LINKCATEGORY; + } + + $catKey = null; + foreach ($candidateKeys as $k) { + if (isset($resources[$k]) && \is_array($resources[$k])) { + $catKey = $k; + + break; + } + } + + if (null === $catKey) { + $this->dlog('restore_link_category: no category bucket in course->resources'); + + return 0; + } + + // Locate the category wrapper by 3 strategies: array key, wrapper->source_id, inner obj->id + $bucket = $resources[$catKey]; + + // by integer array key + $byIntKey = []; + foreach ($bucket as $k => $wrap) { + $ik = is_numeric($k) ? (int) $k : 0; + if ($ik > 0) { + $byIntKey[$ik] = $wrap; + } + } + + // by wrapper->source_id + $bySourceId = []; + foreach ($bucket as $wrap) { + if (!\is_object($wrap)) { + continue; + } + $sid = isset($wrap->source_id) ? (int) $wrap->source_id : 0; + if ($sid > 0) { + $bySourceId[$sid] = $wrap; + } + } + + // by inner entity id (obj->id) + $byObjId = []; + foreach ($bucket as $wrap) { + if (\is_object($wrap) && isset($wrap->obj) && \is_object($wrap->obj)) { + $oid = isset($wrap->obj->id) ? (int) $wrap->obj->id : 0; + if ($oid > 0) { + $byObjId[$oid] = $wrap; + } + } + } + + $iid = (int) $id; + $srcCat = $byIntKey[$iid] + ?? $bySourceId[$iid] + ?? $byObjId[$iid] + ?? ($bucket[(string) $id] ?? ($bucket[$id] ?? null)); + + if (!\is_object($srcCat)) { + $this->dlog('restore_link_category: source category object not found', [ + 'asked_id' => $iid, + 'bucket' => $catKey, + 'keys_seen' => \array_slice(array_keys((array) $bucket), 0, 12), + 'index_hit' => [ + 'byIntKey' => isset($byIntKey[$iid]), + 'bySourceId' => isset($bySourceId[$iid]), + 'byObjId' => isset($byObjId[$iid]), + ], + ]); return 0; } - // Already restored? - if (!empty($srcCat->destination_id)) { + // Already mapped? + if ((int) $srcCat->destination_id > 0) { return (int) $srcCat->destination_id; } + // Unwrap/normalize fields + $e = (isset($srcCat->obj) && \is_object($srcCat->obj)) ? $srcCat->obj : $srcCat; + $title = trim((string) ($e->title ?? $e->category_title ?? ($srcCat->extra['title'] ?? '') ?? '')); + if ('' === $title) { + $title = 'Links'; + } + $description = (string) ($e->description ?? ($srcCat->extra['description'] ?? '') ?? ''); + $em = Database::getManager(); $catRepo = Container::getLinkCategoryRepository(); $course = api_get_course_entity($this->destination_course_id); - $session = api_get_session_entity($sessionId); - - // Normalize incoming values - $title = (string) ($srcCat->title ?? $srcCat->category_title ?? 'Links'); - $description = (string) ($srcCat->description ?? ''); - - // Try to find existing category by *title* under this course (we'll filter by course parent in PHP) - $candidates = $catRepo->findBy(['title' => $title]); + $session = api_get_session_entity((int) $sessionId); + // Look for an existing category under the same course (by title) $existing = null; + $candidates = $catRepo->findBy(['title' => $title]); if (!empty($candidates)) { $courseNode = $course->getResourceNode(); foreach ($candidates as $cand) { @@ -1099,112 +1280,104 @@ public function restore_link_category($id, $sessionId = 0) $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null; if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) { $existing = $cand; + break; } } } - // Collision handling if ($existing) { - switch ($this->file_option) { - case FILE_SKIP: - $destIid = (int) $existing->getIid(); - $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid; - $this->dlog('restore_link_category: reuse (SKIP)', [ - 'src_cat_id' => (int) $id, - 'dst_cat_id' => $destIid, - 'title' => $title, - ]); - - return $destIid; + if (FILE_SKIP === $this->file_option) { + $destIid = (int) $existing->getIid(); + // Write back to the SAME wrapper we located + $srcCat->destination_id = $destIid; + $this->dlog('restore_link_category: reuse (SKIP)', [ + 'src_cat_id' => $iid, 'dst_cat_id' => $destIid, 'title' => $title, + ]); - case FILE_OVERWRITE: - // Update description (keep title) - $existing->setDescription($description); - // Ensure course/session link - if (method_exists($existing, 'setParent')) { - $existing->setParent($course); - } - if (method_exists($existing, 'addCourseLink')) { - $existing->addCourseLink($course, $session); - } + return $destIid; + } - $em->persist($existing); - $em->flush(); + if (FILE_OVERWRITE === $this->file_option) { + $existing->setDescription($description); + if (method_exists($existing, 'setParent')) { + $existing->setParent($course); + } + if (method_exists($existing, 'addCourseLink')) { + $existing->addCourseLink($course, $session); + } + $em->persist($existing); + $em->flush(); - $destIid = (int) $existing->getIid(); - $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid; - $this->dlog('restore_link_category: overwrite', [ - 'src_cat_id' => (int) $id, - 'dst_cat_id' => $destIid, - 'title' => $title, - ]); + $destIid = (int) $existing->getIid(); + $srcCat->destination_id = $destIid; + $this->dlog('restore_link_category: overwrite', [ + 'src_cat_id' => $iid, 'dst_cat_id' => $destIid, 'title' => $title, + ]); - return $destIid; + return $destIid; + } - case FILE_RENAME: - default: - // Create a new unique title inside the same course parent - $base = $title; - $i = 1; - do { - $title = $base . ' (' . $i . ')'; - $candidates = $catRepo->findBy(['title' => $title]); - $exists = false; - - if (!empty($candidates)) { - $courseNode = $course->getResourceNode(); - foreach ($candidates as $cand) { - $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null; - $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null; - if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) { - $exists = true; - break; - } - } - } + // FILE_RENAME policy + $base = $title; + $i = 1; + $exists = true; + do { + $title = $base.' ('.$i++.')'; + $exists = false; + foreach ($catRepo->findBy(['title' => $title]) as $cand) { + $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null; + $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null; + if ($parent && $parent->getId() === $course->getResourceNode()->getId()) { + $exists = true; - $i++; - } while ($exists); - break; - } + break; + } + } + } while ($exists); } // Create new category $cat = (new CLinkCategory()) ->setTitle($title) - ->setDescription($description); + ->setDescription($description) + ; if (method_exists($cat, 'setParent')) { - $cat->setParent($course); // parent ResourceNode: Course + $cat->setParent($course); } if (method_exists($cat, 'addCourseLink')) { - $cat->addCourseLink($course, $session); // visibility link (course, session) + $cat->addCourseLink($course, $session); } $em->persist($cat); $em->flush(); $destIid = (int) $cat->getIid(); - $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid; + + // Write back to the SAME wrapper we located (object is by reference) + $srcCat->destination_id = $destIid; $this->dlog('restore_link_category: created', [ - 'src_cat_id' => (int) $id, - 'dst_cat_id' => $destIid, - 'title' => (string) $title, + 'src_cat_id' => $iid, 'dst_cat_id' => $destIid, 'title' => $title, 'bucket' => $catKey, ]); return $destIid; } - public function restore_links($session_id = 0) + /** + * Restore course links. + * + * @param mixed $session_id + */ + public function restore_links($session_id = 0): void { if (!$this->course->has_resources(RESOURCE_LINK)) { return; } $resources = $this->course->resources; - $count = is_array($resources[RESOURCE_LINK] ?? null) ? count($resources[RESOURCE_LINK]) : 0; + $count = \is_array($resources[RESOURCE_LINK] ?? null) ? \count($resources[RESOURCE_LINK]) : 0; $this->dlog('restore_links: begin', ['count' => $count]); @@ -1216,14 +1389,11 @@ public function restore_links($session_id = 0) // Safe duplicate finder (no dot-path in criteria; filter parent in PHP) $findDuplicate = function (string $t, string $u, ?CLinkCategory $cat) use ($linkRepo, $course) { - $criteria = ['title' => $t, 'url' => $u]; - $criteria['category'] = $cat instanceof CLinkCategory ? $cat : null; - + $criteria = ['title' => $t, 'url' => $u, 'category' => ($cat instanceof CLinkCategory ? $cat : null)]; $candidates = $linkRepo->findBy($criteria); if (empty($candidates)) { return null; } - $courseNode = $course->getResourceNode(); foreach ($candidates as $cand) { $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null; @@ -1237,23 +1407,32 @@ public function restore_links($session_id = 0) }; foreach ($resources[RESOURCE_LINK] as $oldLinkId => $link) { - // Normalize (accept values from object or "extra") $rawUrl = (string) ($link->url ?? ($link->extra['url'] ?? '')); $rawTitle = (string) ($link->title ?? ($link->extra['title'] ?? '')); $rawDesc = (string) ($link->description ?? ($link->extra['description'] ?? '')); $target = isset($link->target) ? (string) $link->target : null; + + // Prefer plain category_id, fallback to linked_resources if needed $catSrcId = (int) ($link->category_id ?? 0); + if ($catSrcId <= 0 && isset($link->linked_resources['Link_Category'][0])) { + $catSrcId = (int) $link->linked_resources['Link_Category'][0]; + } + if ($catSrcId <= 0 && isset($link->linked_resources['link_category'][0])) { + $catSrcId = (int) $link->linked_resources['link_category'][0]; + } + $onHome = (bool) ($link->on_homepage ?? false); $url = trim($rawUrl); - $title = trim($rawTitle) !== '' ? trim($rawTitle) : $url; + $title = '' !== trim($rawTitle) ? trim($rawTitle) : $url; - if ($url === '') { + if ('' === $url) { $this->dlog('restore_links: skipped (empty URL)', [ 'src_link_id' => (int) $oldLinkId, 'has_obj' => !empty($link->has_obj), 'extra_keys' => isset($link->extra) ? implode(',', array_keys((array) $link->extra)) : '', ]); + continue; } @@ -1271,13 +1450,13 @@ public function restore_links($session_id = 0) } } - // Duplicate handling (title + url + category in same course) + // Dedup (title + url + category in same course) $existing = $findDuplicate($title, $url, $category); if ($existing) { - if ($this->file_option === FILE_SKIP) { + if (FILE_SKIP === $this->file_option) { $destIid = (int) $existing->getIid(); - $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new \stdClass(); + $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new stdClass(); $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid; $this->dlog('restore_links: reuse (SKIP)', [ @@ -1290,13 +1469,15 @@ public function restore_links($session_id = 0) continue; } - if ($this->file_option === FILE_OVERWRITE) { - // Update main fields (keep position/shortcut logic outside) + if (FILE_OVERWRITE === $this->file_option) { + $descHtml = $this->rewriteHtmlForCourse($rawDesc, (int) $session_id, '[links.link.overwrite]'); + $existing ->setUrl($url) ->setTitle($title) - ->setDescription($rawDesc) // rewrite to assets after flush - ->setTarget((string) ($target ?? '')); + ->setDescription($descHtml) + ->setTarget((string) ($target ?? '')) + ; if (method_exists($existing, 'setParent')) { $existing->setParent($course); @@ -1304,36 +1485,13 @@ public function restore_links($session_id = 0) if (method_exists($existing, 'addCourseLink')) { $existing->addCourseLink($course, $session); } - $existing->setCategory($category); // can be null + $existing->setCategory($category); // may be null $em->persist($existing); $em->flush(); - // Now rewrite legacy "document/..." URLs inside description to Assets - try { - $backupRoot = $this->course->backup_path ?? ''; - $extraRoots = array_filter([ - $this->course->destination_path ?? '', - $this->course->origin_path ?? '', - ]); - $rewritten = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $rawDesc, - $existing, - $backupRoot, - $extraRoots - ); - - if ($rewritten !== $rawDesc) { - $existing->setDescription($rewritten); - $em->persist($existing); - $em->flush(); - } - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: restore_links: asset rewrite failed (overwrite): ' . $e->getMessage()); - } - $destIid = (int) $existing->getIid(); - $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new \stdClass(); + $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new stdClass(); $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid; $this->dlog('restore_links: overwrite', [ @@ -1346,66 +1504,42 @@ public function restore_links($session_id = 0) continue; } - // FILE_RENAME (default): make title unique among same course/category + // FILE_RENAME flow $base = $title; $i = 1; do { - $title = $base . ' (' . $i . ')'; + $title = $base.' ('.$i.')'; $i++; } while ($findDuplicate($title, $url, $category)); } + $descHtml = $this->rewriteHtmlForCourse($rawDesc, (int) $session_id, '[links.link.create]'); + // Create new link entity $entity = (new CLink()) ->setUrl($url) ->setTitle($title) - ->setDescription($rawDesc) // rewrite to assets after first flush - ->setTarget((string) ($target ?? '')); + ->setDescription($descHtml) + ->setTarget((string) ($target ?? '')) + ; if (method_exists($entity, 'setParent')) { - $entity->setParent($course); // parent ResourceNode: Course + $entity->setParent($course); } if (method_exists($entity, 'addCourseLink')) { - $entity->addCourseLink($course, $session); // visibility (course, session) + $entity->addCourseLink($course, $session); } if ($category instanceof CLinkCategory) { $entity->setCategory($category); } - // Persist to create the ResourceNode; we need it for Asset attachment $em->persist($entity); $em->flush(); - // Rewrite legacy "document/..." URLs inside description to Assets, then save if changed - try { - $backupRoot = $this->course->backup_path ?? ''; - $extraRoots = array_filter([ - $this->course->destination_path ?? '', - $this->course->origin_path ?? '', - ]); - $rewritten = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $rawDesc, - $entity, - (string) $backupRoot, - $extraRoots - ); - - if ($rewritten !== (string) $rawDesc) { - $entity->setDescription($rewritten); - $em->persist($entity); - $em->flush(); - } - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: restore_links: asset rewrite failed (create): ' . $e->getMessage()); - } - // Map destination id back into resources $destIid = (int) $entity->getIid(); - - if (!isset($this->course->resources[RESOURCE_LINK][$oldLinkId])) { - $this->course->resources[RESOURCE_LINK][$oldLinkId] = new \stdClass(); - } + $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new stdClass(); $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid; $this->dlog('restore_links: created', [ @@ -1416,15 +1550,12 @@ public function restore_links($session_id = 0) 'category' => $category ? $category->getTitle() : null, ]); - // Optional: emulate "show on homepage" by ensuring ResourceLink exists (UI/Controller handles real shortcut) if (!empty($onHome)) { try { - // Ensure resource link is persisted (it already is via addCourseLink) - // Any actual shortcut creation should be delegated to the appropriate service/controller. $em->persist($entity); $em->flush(); - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: restore_links: homepage flag handling failed: ' . $e->getMessage()); + } catch (Throwable $e) { + error_log('COURSE_DEBUG: restore_links: homepage flag handling failed: '.$e->getMessage()); } } } @@ -1432,100 +1563,77 @@ public function restore_links($session_id = 0) $this->dlog('restore_links: end'); } - public function restore_tool_intro($sessionId = 0) + /** + * Restore tool introductions. + * + * @param mixed $sessionId + */ + public function restore_tool_intro($sessionId = 0): void { $resources = $this->course->resources ?? []; + $bagKey = null; if ($this->course->has_resources(RESOURCE_TOOL_INTRO)) { $bagKey = RESOURCE_TOOL_INTRO; } elseif (!empty($resources['Tool introduction'])) { $bagKey = 'Tool introduction'; } - if ($bagKey === null || empty($resources[$bagKey]) || !is_array($resources[$bagKey])) { + if (null === $bagKey || empty($resources[$bagKey]) || !\is_array($resources[$bagKey])) { return; } $sessionId = (int) $sessionId; - $this->dlog('restore_tool_intro: begin', ['count' => count($resources[$bagKey])]); + $this->dlog('restore_tool_intro: begin', ['count' => \count($resources[$bagKey])]); - $em = \Database::getManager(); - $course = api_get_course_entity($this->destination_course_id); + $em = Database::getManager(); + $course = api_get_course_entity($this->destination_course_id); $session = $sessionId ? api_get_session_entity($sessionId) : null; - $toolRepo = $em->getRepository(Tool::class); + $toolRepo = $em->getRepository(Tool::class); $cToolRepo = $em->getRepository(CTool::class); $introRepo = $em->getRepository(CToolIntro::class); - $rewriteContent = function (string $html) { - if ($html === '') return ''; - try { - if (class_exists(ChamiloHelper::class) - && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets') - ) { - return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $html, - api_get_course_entity($this->destination_course_id), - (string)($this->course->backup_path ?? ''), - array_filter([ - (string)($this->course->destination_path ?? ''), - (string)($this->course->info['path'] ?? ''), - ]) - ); - } - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed (tool_intro): '.$e->getMessage()); - } - - $out = \DocumentManager::replaceUrlWithNewCourseCode( - $html, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - return $out === false ? '' : $out; - }; - foreach ($resources[$bagKey] as $rawId => $tIntro) { - // prefer source->id only if non-empty AND not "0"; otherwise use the bag key ($rawId) - $toolKey = trim((string)($tIntro->id ?? '')); - if ($toolKey === '' || $toolKey === '0') { - $toolKey = (string)$rawId; + // Resolve tool key + $toolKey = trim((string) ($tIntro->id ?? '')); + if ('' === $toolKey || '0' === $toolKey) { + $toolKey = (string) $rawId; } - - // normalize a couple of common aliases defensively $alias = strtolower($toolKey); - if ($alias === 'homepage' || $alias === 'course_home') { + if ('homepage' === $alias || 'course_home' === $alias) { $toolKey = 'course_homepage'; } - // log exactly what we got to avoid future confusion $this->dlog('restore_tool_intro: resolving tool key', [ - 'raw_id' => (string)$rawId, - 'obj_id' => isset($tIntro->id) ? (string)$tIntro->id : null, + 'raw_id' => (string) $rawId, + 'obj_id' => isset($tIntro->id) ? (string) $tIntro->id : null, 'toolKey' => $toolKey, ]); - $mapped = $tIntro->destination_id ?? 0; + // Already mapped? + $mapped = (int) ($tIntro->destination_id ?? 0); if ($mapped > 0) { $this->dlog('restore_tool_intro: already mapped, skipping', ['src_id' => $toolKey, 'dst_id' => $mapped]); + continue; } - $introHtml = $rewriteContent($tIntro->intro_text ?? ''); + // Rewrite HTML using the central helper + $introHtml = $this->rewriteHtmlForCourse((string) ($tIntro->intro_text ?? ''), $sessionId, '[tool_intro.intro]'); - // find core Tool by title (e.g., 'course_homepage') + // Find platform Tool entity $toolEntity = $toolRepo->findOneBy(['title' => $toolKey]); if (!$toolEntity) { $this->dlog('restore_tool_intro: missing Tool entity, skipping', ['tool' => $toolKey]); + continue; } - // find or create the CTool row for this course+session+title + // Find or create course tool (CTool) $cTool = $cToolRepo->findOneBy([ - 'course' => $course, + 'course' => $course, 'session' => $session, - 'title' => $toolKey, + 'title' => $toolKey, ]); if (!$cTool) { @@ -1538,24 +1646,26 @@ public function restore_tool_intro($sessionId = 0) ->setVisibility(true) ->setParent($course) ->setCreator($course->getCreator() ?? null) - ->addCourseLink($course); + ->addCourseLink($course) + ; $em->persist($cTool); $em->flush(); $this->dlog('restore_tool_intro: CTool created', [ - 'tool' => $toolKey, - 'ctool_id' => (int)$cTool->getIid(), + 'tool' => $toolKey, + 'ctool_id' => (int) $cTool->getIid(), ]); } + // Intro entity (create/overwrite/skip according to policy) $intro = $introRepo->findOneBy(['courseTool' => $cTool]); if ($intro) { - if ($this->file_option === FILE_SKIP) { + if (FILE_SKIP === $this->file_option) { $this->dlog('restore_tool_intro: reuse existing (SKIP)', [ - 'tool' => $toolKey, - 'intro_id' => (int)$intro->getIid(), + 'tool' => $toolKey, + 'intro_id' => (int) $intro->getIid(), ]); } else { $intro->setIntroText($introHtml); @@ -1563,142 +1673,117 @@ public function restore_tool_intro($sessionId = 0) $em->flush(); $this->dlog('restore_tool_intro: intro overwritten', [ - 'tool' => $toolKey, - 'intro_id' => (int)$intro->getIid(), + 'tool' => $toolKey, + 'intro_id' => (int) $intro->getIid(), ]); } } else { $intro = (new CToolIntro()) ->setCourseTool($cTool) ->setIntroText($introHtml) - ->setParent($course); + ->setParent($course) + ; $em->persist($intro); $em->flush(); $this->dlog('restore_tool_intro: intro created', [ - 'tool' => $toolKey, - 'intro_id' => (int)$intro->getIid(), + 'tool' => $toolKey, + 'intro_id' => (int) $intro->getIid(), ]); } - // map destination back into the legacy resource bag - if (!isset($this->course->resources[$bagKey][$rawId])) { - $this->course->resources[$bagKey][$rawId] = new \stdClass(); - } - $this->course->resources[$bagKey][$rawId]->destination_id = (int)$intro->getIid(); + // Map destination id back + $this->course->resources[$bagKey][$rawId] ??= new stdClass(); + $this->course->resources[$bagKey][$rawId]->destination_id = (int) $intro->getIid(); } $this->dlog('restore_tool_intro: end'); } - + /** + * Restore calendar events. + */ public function restore_events(int $sessionId = 0): void { if (!$this->course->has_resources(RESOURCE_EVENT)) { return; } - $resources = $this->course->resources ?? []; - $bag = $resources[RESOURCE_EVENT] ?? []; - $count = is_array($bag) ? count($bag) : 0; + $resources = $this->course->resources ?? []; + $bag = $resources[RESOURCE_EVENT] ?? []; + $count = \is_array($bag) ? \count($bag) : 0; $this->dlog('restore_events: begin', ['count' => $count]); /** @var EntityManagerInterface $em */ - $em = \Database::getManager(); - $course = api_get_course_entity($this->destination_course_id); - $session = api_get_session_entity($sessionId); - $group = api_get_group_entity(); - $eventRepo = Container::getCalendarEventRepository(); - $attachRepo = Container::getCalendarEventAttachmentRepository(); - - // Content rewrite helper (prefer new helper if available) - $rewriteContent = function (?string $html): string { - $html = $html ?? ''; - if ($html === '') { - return ''; - } - try { - if (method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { - return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $html, - api_get_course_entity($this->destination_course_id), - $this->course->backup_path ?? '', - array_filter([ - $this->course->destination_path ?? '', - (string) ($this->course->info['path'] ?? ''), - ]) - ); - } - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); - } - - $out = \DocumentManager::replaceUrlWithNewCourseCode( - $html, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - - return $out === false ? '' : (string) $out; - }; + $em = Database::getManager(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity($sessionId); + $group = api_get_group_entity(); + $eventRepo = Container::getCalendarEventRepository(); + $attachRepo = Container::getCalendarEventAttachmentRepository(); - // Dedupe by title inside same course/session (honor sameFileNameOption) + // Dedupe by title inside same course/session $findExistingByTitle = function (string $title) use ($eventRepo, $course, $session) { $qb = $eventRepo->getResourcesByCourse($course, $session, null, null, true, true); $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1); + return $qb->getQuery()->getOneOrNullResult(); }; - // Attachment source in backup zip (calendar) - $originPath = rtrim((string)($this->course->backup_path ?? ''), '/').'/upload/calendar/'; + $originPath = rtrim((string) ($this->course->backup_path ?? ''), '/').'/upload/calendar/'; foreach ($bag as $oldId => $raw) { - // Skip if already mapped to a positive destination id + // Already mapped? $mapped = (int) ($raw->destination_id ?? 0); if ($mapped > 0) { - $this->dlog('restore_events: already mapped, skipping', ['src_id' => (int)$oldId, 'dst_id' => $mapped]); + $this->dlog('restore_events: already mapped, skipping', ['src_id' => (int) $oldId, 'dst_id' => $mapped]); + continue; } - // Normalize input - $title = trim((string)($raw->title ?? '')); - if ($title === '') { + // Normalize + rewrite content + $title = trim((string) ($raw->title ?? '')); + if ('' === $title) { $title = 'Event'; } - $content = $rewriteContent((string)($raw->content ?? '')); + $content = $this->rewriteHtmlForCourse((string) ($raw->content ?? ''), $sessionId, '[events.content]'); + + // Dates + $allDay = (bool) ($raw->all_day ?? false); + $start = null; + $end = null; - // Dates: accept various formats; allow empty endDate - $allDay = (bool)($raw->all_day ?? false); - $start = null; - $end = null; try { - $s = (string)($raw->start_date ?? ''); - if ($s !== '') { $start = new \DateTime($s); } - } catch (\Throwable $e) { $start = null; } + $s = (string) ($raw->start_date ?? ''); + if ('' !== $s) { + $start = new DateTime($s); + } + } catch (Throwable) { + } + try { - $e = (string)($raw->end_date ?? ''); - if ($e !== '') { $end = new \DateTime($e); } - } catch (\Throwable $e) { $end = null; } + $e = (string) ($raw->end_date ?? ''); + if ('' !== $e) { + $end = new DateTime($e); + } + } catch (Throwable) { + } // Dedupe policy $existing = $findExistingByTitle($title); if ($existing) { switch ($this->file_option) { case FILE_SKIP: - $destId = (int)$existing->getIid(); - $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass(); + $destId = (int) $existing->getIid(); + $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass(); $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId; - $this->dlog('restore_events: reuse (SKIP)', [ - 'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $existing->getTitle() - ]); - // Try to add missing attachments (no duplicates by filename) + $this->dlog('restore_events: reuse (SKIP)', ['src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $existing->getTitle()]); $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em); - break; + + continue 2; case FILE_OVERWRITE: $existing @@ -1706,23 +1791,22 @@ public function restore_events(int $sessionId = 0): void ->setContent($content) ->setAllDay($allDay) ->setParent($course) - ->addCourseLink($course, $session, $group); - + ->addCourseLink($course, $session, $group) + ; $existing->setStartDate($start); $existing->setEndDate($end); $em->persist($existing); $em->flush(); - $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass(); - $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = (int)$existing->getIid(); + $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass(); + $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = (int) $existing->getIid(); - $this->dlog('restore_events: overwrite', [ - 'src_id' => (int)$oldId, 'dst_id' => (int)$existing->getIid(), 'title' => $title - ]); + $this->dlog('restore_events: overwrite', ['src_id' => (int) $oldId, 'dst_id' => (int) $existing->getIid(), 'title' => $title]); $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em); - break; + + continue 2; case FILE_RENAME: default: @@ -1730,21 +1814,22 @@ public function restore_events(int $sessionId = 0): void $i = 1; $candidate = $base; while ($findExistingByTitle($candidate)) { - $i++; - $candidate = $base.' ('.$i.')'; + $candidate = $base.' ('.(++$i).')'; } $title = $candidate; + break; } } - // Create new entity in course context + // Create new event $entity = (new CCalendarEvent()) ->setTitle($title) ->setContent($content) ->setAllDay($allDay) ->setParent($course) - ->addCourseLink($course, $session, $group); + ->addCourseLink($course, $session, $group) + ; $entity->setStartDate($start); $entity->setEndDate($end); @@ -1752,24 +1837,24 @@ public function restore_events(int $sessionId = 0): void $em->persist($entity); $em->flush(); - // Map new id - $destId = (int)$entity->getIid(); - $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass(); + $destId = (int) $entity->getIid(); + $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass(); $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId; - $this->dlog('restore_events: created', ['src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $title]); + $this->dlog('restore_events: created', ['src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $title]); - // Attachments (backup modern / legacy) + // Attachments $this->restoreEventAttachments($raw, $entity, $originPath, $attachRepo, $em); - - // (Optional) Repeat rules / reminders: - // If your backup exports recurrence/reminders, parse here and populate CCalendarEventRepeat / AgendaReminder. - // $this->restoreEventRecurrenceAndReminders($raw, $entity, $em); } $this->dlog('restore_events: end'); } + /** + * Handle event attachments. + * + * @param mixed $attachRepo + */ private function restoreEventAttachments( object $raw, CCalendarEvent $entity, @@ -1778,9 +1863,10 @@ private function restoreEventAttachments( EntityManagerInterface $em ): void { // Helper to actually persist + move file - $persistAttachmentFromFile = function (string $src, string $filename, ?string $comment) use ($entity, $attachRepo, $em) { + $persistAttachmentFromFile = function (string $src, string $filename, ?string $comment) use ($entity, $attachRepo, $em): void { if (!is_file($src) || !is_readable($src)) { $this->dlog('restore_events: attachment source not readable', ['src' => $src]); + return; } @@ -1788,6 +1874,7 @@ private function restoreEventAttachments( foreach ($entity->getAttachments() as $att) { if ($att->getFilename() === $filename) { $this->dlog('restore_events: attachment already exists, skipping', ['filename' => $filename]); + return; } } @@ -1801,7 +1888,8 @@ private function restoreEventAttachments( api_get_course_entity($this->destination_course_id), api_get_session_entity(0), api_get_group_entity() - ); + ) + ; $em->persist($attachment); $em->flush(); @@ -1816,88 +1904,67 @@ private function restoreEventAttachments( } $this->dlog('restore_events: attachment created', [ - 'event_id' => (int)$entity->getIid(), + 'event_id' => (int) $entity->getIid(), 'filename' => $filename, ]); }; - // Case 1: modern backup fields on object + // modern backup fields on object if (!empty($raw->attachment_path)) { $src = rtrim($originPath, '/').'/'.$raw->attachment_path; - $filename = (string)($raw->attachment_filename ?? basename($src)); - $comment = (string)($raw->attachment_comment ?? ''); + $filename = (string) ($raw->attachment_filename ?? basename($src)); + $comment = (string) ($raw->attachment_comment ?? ''); $persistAttachmentFromFile($src, $filename, $comment); + return; } - // Case 2: legacy lookup from old course tables when ->orig present + // legacy lookup from old course tables when ->orig present if (!empty($this->course->orig)) { - $table = \Database::get_course_table(TABLE_AGENDA_ATTACHMENT); + $table = Database::get_course_table(TABLE_AGENDA_ATTACHMENT); $sql = 'SELECT path, comment, filename FROM '.$table.' WHERE c_id = '.$this->destination_course_id.' - AND agenda_id = '.(int)($raw->source_id ?? 0); - $res = \Database::query($sql); - while ($row = \Database::fetch_object($res)) { + AND agenda_id = '.(int) ($raw->source_id ?? 0); + $res = Database::query($sql); + while ($row = Database::fetch_object($res)) { $src = rtrim($originPath, '/').'/'.$row->path; - $persistAttachmentFromFile($src, (string)$row->filename, (string)$row->comment); + $persistAttachmentFromFile($src, (string) $row->filename, (string) $row->comment); } } } - public function restore_course_descriptions($session_id = 0) + /** + * Restore course descriptions. + * + * @param mixed $session_id + */ + public function restore_course_descriptions($session_id = 0): void { if (!$this->course->has_resources(RESOURCE_COURSEDESCRIPTION)) { return; } - $resources = $this->course->resources; - $count = is_array($resources[RESOURCE_COURSEDESCRIPTION] ?? null) - ? count($resources[RESOURCE_COURSEDESCRIPTION]) - : 0; + $resources = $this->course->resources ?? []; + $count = \is_array($resources[RESOURCE_COURSEDESCRIPTION] ?? null) + ? \count($resources[RESOURCE_COURSEDESCRIPTION]) : 0; $this->dlog('restore_course_descriptions: begin', ['count' => $count]); - $em = \Database::getManager(); - $repo = Container::getCourseDescriptionRepository(); - $course = api_get_course_entity($this->destination_course_id); + $em = Database::getManager(); + $repo = Container::getCourseDescriptionRepository(); + $course = api_get_course_entity($this->destination_course_id); $session = api_get_session_entity((int) $session_id); - $rewriteContent = function (string $html) use ($course) { - if ($html === '') { - return ''; - } - if (method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { - try { - return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $html, - $course, - $this->course->backup_path ?? '', - array_filter([ - $this->course->destination_path ?? '', - (string)($this->course->info['path'] ?? ''), - ]) - ); - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); - } - } - $out = \DocumentManager::replaceUrlWithNewCourseCode( - $html, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - - return $out === false ? '' : $out; - }; - $findByTypeInCourse = function (int $type) use ($repo, $course, $session) { if (method_exists($repo, 'findByTypeInCourse')) { return $repo->findByTypeInCourse($type, $course, $session); } - $qb = $repo->getResourcesByCourse($course, $session)->andWhere('resource.descriptionType = :t')->setParameter('t', $type); + $qb = $repo->getResourcesByCourse($course, $session) + ->andWhere('resource.descriptionType = :t') + ->setParameter('t', $type) + ; + return $qb->getQuery()->getResult(); }; @@ -1905,128 +1972,122 @@ public function restore_course_descriptions($session_id = 0) $qb = $repo->getResourcesByCourse($course, $session) ->andWhere('resource.title = :t') ->setParameter('t', $title) - ->setMaxResults(1); + ->setMaxResults(1) + ; + return $qb->getQuery()->getOneOrNullResult(); }; foreach ($resources[RESOURCE_COURSEDESCRIPTION] as $oldId => $cd) { - $mapped = (int)($cd->destination_id ?? 0); + // Already mapped? + $mapped = (int) ($cd->destination_id ?? 0); if ($mapped > 0) { - $this->dlog('restore_course_descriptions: already mapped, skipping', [ - 'src_id' => (int)$oldId, - 'dst_id' => $mapped, - ]); + $this->dlog('restore_course_descriptions: already mapped, skipping', ['src_id' => (int) $oldId, 'dst_id' => $mapped]); + continue; } - $rawTitle = (string)($cd->title ?? ''); - $rawContent = (string)($cd->content ?? ''); - $type = (int)($cd->description_type ?? CCourseDescription::TYPE_DESCRIPTION); - $title = trim($rawTitle) !== '' ? trim($rawTitle) : $rawTitle; - $content = $rewriteContent($rawContent); + // Normalize + rewrite + $rawTitle = (string) ($cd->title ?? ''); + $rawContent = (string) ($cd->content ?? ''); + $type = (int) ($cd->description_type ?? CCourseDescription::TYPE_DESCRIPTION); + + $title = '' !== trim($rawTitle) ? trim($rawTitle) : $rawTitle; + $content = $this->rewriteHtmlForCourse($rawContent, (int) $session_id, '[course_description.content]'); + // Policy by type $existingByType = $findByTypeInCourse($type); - $existingOne = $existingByType[0] ?? null; + $existingOne = $existingByType[0] ?? null; if ($existingOne) { switch ($this->file_option) { case FILE_SKIP: - $destIid = (int)$existingOne->getIid(); - $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new \stdClass(); + $destIid = (int) $existingOne->getIid(); + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass(); $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid; $this->dlog('restore_course_descriptions: reuse (SKIP)', [ - 'src_id' => (int)$oldId, + 'src_id' => (int) $oldId, 'dst_id' => $destIid, - 'type' => $type, - 'title' => (string)$existingOne->getTitle(), + 'type' => $type, + 'title' => (string) $existingOne->getTitle(), ]); - break; + + continue 2; case FILE_OVERWRITE: $existingOne - ->setTitle($title !== '' ? $title : (string)$existingOne->getTitle()) + ->setTitle('' !== $title ? $title : (string) $existingOne->getTitle()) ->setContent($content) ->setDescriptionType($type) - ->setProgress((int)($cd->progress ?? 0)); + ->setProgress((int) ($cd->progress ?? 0)) + ; $existingOne->setParent($course)->addCourseLink($course, $session); $em->persist($existingOne); $em->flush(); - $destIid = (int)$existingOne->getIid(); - $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new \stdClass(); + $destIid = (int) $existingOne->getIid(); + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass(); $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid; $this->dlog('restore_course_descriptions: overwrite', [ - 'src_id' => (int)$oldId, + 'src_id' => (int) $oldId, 'dst_id' => $destIid, - 'type' => $type, - 'title' => (string)$existingOne->getTitle(), + 'type' => $type, + 'title' => (string) $existingOne->getTitle(), ]); - break; + + continue 2; case FILE_RENAME: default: - $base = $title !== '' ? $title : (string)($cd->extra['title'] ?? 'Description'); + $base = '' !== $title ? $title : (string) ($cd->extra['title'] ?? 'Description'); $i = 1; $candidate = $base; while ($findByTitleInCourse($candidate)) { - $i++; - $candidate = $base.' ('.$i.')'; + $candidate = $base.' ('.(++$i).')'; } $title = $candidate; + break; } } + // Create new $entity = (new CCourseDescription()) ->setTitle($title) ->setContent($content) ->setDescriptionType($type) - ->setProgress((int)($cd->progress ?? 0)) + ->setProgress((int) ($cd->progress ?? 0)) ->setParent($course) - ->addCourseLink($course, $session); + ->addCourseLink($course, $session) + ; $em->persist($entity); $em->flush(); - $destIid = (int)$entity->getIid(); - - if (!isset($this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId])) { - $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] = new \stdClass(); - } + $destIid = (int) $entity->getIid(); + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass(); $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid; $this->dlog('restore_course_descriptions: created', [ - 'src_id' => (int)$oldId, + 'src_id' => (int) $oldId, 'dst_id' => $destIid, - 'type' => $type, - 'title' => $title, + 'type' => $type, + 'title' => $title, ]); } $this->dlog('restore_course_descriptions: end'); } - private function resourceFileAbsPathFromAnnouncementAttachment(CAnnouncementAttachment $att): ?string - { - $node = $att->getResourceNode(); - if (!$node) return null; - - $file = $node->getFirstResourceFile(); - if (!$file) return null; - - /** @var ResourceNodeRepository $rnRepo */ - $rnRepo = Container::$container->get(ResourceNodeRepository::class); - $rel = $rnRepo->getFilename($file); - if (!$rel) return null; - - $abs = $this->projectUploadBase().$rel; - return is_readable($abs) ? $abs : null; - } - - public function restore_announcements($sessionId = 0) + /** + * Restore announcements into the destination course. + * + * @param mixed $sessionId + */ + public function restore_announcements($sessionId = 0): void { if (!$this->course->has_resources(RESOURCE_ANNOUNCEMENT)) { return; @@ -2035,338 +2096,530 @@ public function restore_announcements($sessionId = 0) $sessionId = (int) $sessionId; $resources = $this->course->resources; - $count = is_array($resources[RESOURCE_ANNOUNCEMENT] ?? null) - ? count($resources[RESOURCE_ANNOUNCEMENT]) + $count = \is_array($resources[RESOURCE_ANNOUNCEMENT] ?? null) + ? \count($resources[RESOURCE_ANNOUNCEMENT]) : 0; $this->dlog('restore_announcements: begin', ['count' => $count]); /** @var EntityManagerInterface $em */ - $em = \Database::getManager(); - $course = api_get_course_entity($this->destination_course_id); - $session = api_get_session_entity($sessionId); - $group = api_get_group_entity(); - $annRepo = Container::getAnnouncementRepository(); + $em = Database::getManager(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity($sessionId); + $group = api_get_group_entity(); + $annRepo = Container::getAnnouncementRepository(); $attachRepo = Container::getAnnouncementAttachmentRepository(); - $rewriteContent = function (string $html) { - if ($html === '') return ''; - try { - if (class_exists(ChamiloHelper::class) - && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { - return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $html, - api_get_course_entity($this->destination_course_id), - $this->course->backup_path ?? '', - array_filter([ - $this->course->destination_path ?? '', - (string)($this->course->info['path'] ?? ''), - ]) - ); - } - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); - } - - $out = \DocumentManager::replaceUrlWithNewCourseCode( - $html, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - - return $out === false ? '' : $out; - }; + // Origin path for ZIP/imported attachments (kept as-is) + $originPath = rtrim($this->course->backup_path ?? '', '/').'/upload/announcements/'; + // Finder: existing announcement by title in this course/session $findExistingByTitle = function (string $title) use ($annRepo, $course, $session) { $qb = $annRepo->getResourcesByCourse($course, $session); $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1); + return $qb->getQuery()->getOneOrNullResult(); }; - $originPath = rtrim($this->course->backup_path ?? '', '/').'/upload/announcements/'; - foreach ($resources[RESOURCE_ANNOUNCEMENT] as $oldId => $a) { - $mapped = (int)($a->destination_id ?? 0); + // Already mapped? + $mapped = (int) ($a->destination_id ?? 0); if ($mapped > 0) { $this->dlog('restore_announcements: already mapped, skipping', [ - 'src_id' => (int)$oldId, 'dst_id' => $mapped + 'src_id' => (int) $oldId, 'dst_id' => $mapped, ]); + continue; } - $title = trim((string)($a->title ?? '')); - if ($title === '') { $title = 'Announcement'; } + $title = trim((string) ($a->title ?? '')); + if ('' === $title) { + $title = 'Announcement'; + } - $contentHtml = (string)($a->content ?? ''); - $contentHtml = $rewriteContent($contentHtml); + $contentHtml = (string) ($a->content ?? ''); + // Parse optional end date $endDate = null; + try { - $rawDate = (string)($a->date ?? ''); - if ($rawDate !== '') { $endDate = new \DateTime($rawDate); } - } catch (\Throwable $e) { $endDate = null; } + $rawDate = (string) ($a->date ?? ''); + if ('' !== $rawDate) { + $endDate = new DateTime($rawDate); + } + } catch (Throwable $e) { + $endDate = null; + } - $emailSent = (bool)($a->email_sent ?? false); + $emailSent = (bool) ($a->email_sent ?? false); $existing = $findExistingByTitle($title); if ($existing) { switch ($this->file_option) { case FILE_SKIP: - $destId = (int)$existing->getIid(); - $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass(); + $destId = (int) $existing->getIid(); + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new stdClass(); $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId; $this->dlog('restore_announcements: reuse (SKIP)', [ - 'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $existing->getTitle() - ]); - break; - - case FILE_OVERWRITE: - $existing - ->setTitle($title) - ->setContent($contentHtml) - ->setParent($course) - ->addCourseLink($course, $session, $group) - ->setEmailSent($emailSent); - if ($endDate instanceof \DateTimeInterface) { $existing->setEndDate($endDate); } - $em->persist($existing); - $em->flush(); - - $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass(); - $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = (int)$existing->getIid(); - - $this->dlog('restore_announcements: overwrite', [ - 'src_id' => (int)$oldId, 'dst_id' => (int)$existing->getIid(), 'title' => $title + 'src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $existing->getTitle(), ]); - + // Still try to restore attachments on the reused entity $this->restoreAnnouncementAttachments($a, $existing, $originPath, $attachRepo, $em); + continue 2; + case FILE_OVERWRITE: + // Continue to overwrite below + break; + case FILE_RENAME: default: - $base = $title; $i = 1; $candidate = $base; - while ($findExistingByTitle($candidate)) { $i++; $candidate = $base.' ('.$i.')'; } + // Rename to avoid collision + $base = $title; + $i = 1; + $candidate = $base; + while ($findExistingByTitle($candidate)) { + $i++; + $candidate = $base.' ('.$i.')'; + } $title = $candidate; + break; } } - $entity = (new CAnnouncement()) + // Rewrite HTML content using centralized helper (replaces manual mapping logic) + // Note: keeps attachments restoration logic unchanged. + $contentRewritten = $this->rewriteHtmlForCourse($contentHtml, $sessionId, '[announcements.content]'); + + // Create or reuse entity + $entity = $existing ?: (new CAnnouncement()); + $entity ->setTitle($title) - ->setContent($contentHtml) + ->setContent($contentRewritten) // content already rewritten ->setParent($course) ->addCourseLink($course, $session, $group) - ->setEmailSent($emailSent); - if ($endDate instanceof \DateTimeInterface) { $entity->setEndDate($endDate); } + ->setEmailSent($emailSent) + ; + + if ($endDate instanceof DateTimeInterface) { + $entity->setEndDate($endDate); + } $em->persist($entity); $em->flush(); - $destId = (int)$entity->getIid(); - $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass(); + $destId = (int) $entity->getIid(); + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new stdClass(); $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId; - $this->dlog('restore_announcements: created', [ - 'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $title + $this->dlog($existing ? 'restore_announcements: overwrite' : 'restore_announcements: created', [ + 'src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $title, ]); + // Handle binary attachments from backup or source $this->restoreAnnouncementAttachments($a, $entity, $originPath, $attachRepo, $em); } $this->dlog('restore_announcements: end'); } + /** + * Create/update CAnnouncementAttachment + ResourceFile for each attachment of an announcement. + * Sources: + * - COPY mode (no ZIP): from source announcement's ResourceFiles + * - IMPORT mode (ZIP): from /upload/announcements/* inside the package. + * + * Policies (by filename within the same announcement): + * - FILE_SKIP: skip if filename exists + * - FILE_OVERWRITE: reuse existing CAnnouncementAttachment and replace its ResourceFile + * - FILE_RENAME: create a new CAnnouncementAttachment with incremental suffix + */ private function restoreAnnouncementAttachments( object $a, CAnnouncement $entity, string $originPath, - $attachRepo, + CAnnouncementAttachmentRepository $attachRepo, EntityManagerInterface $em ): void { $copyMode = empty($this->course->backup_path); + $findExistingByName = static function (CAnnouncement $ann, string $name) { + foreach ($ann->getAttachments() as $att) { + if ($att->getFilename() === $name) { + return $att; + } + } + + return null; + }; + + /** + * Decide target entity + final filename according to file policy. + * Returns [CAnnouncementAttachment|null $target, string|null $finalName, bool $isOverwrite]. + */ + $decideTarget = function (string $proposed, CAnnouncement $ann) use ($findExistingByName): array { + $policy = (int) $this->file_option; + + $existing = $findExistingByName($ann, $proposed); + if (!$existing) { + return [null, $proposed, false]; + } + + if (\defined('FILE_SKIP') && FILE_SKIP === $policy) { + return [null, null, false]; + } + if (\defined('FILE_OVERWRITE') && FILE_OVERWRITE === $policy) { + return [$existing, $proposed, true]; + } + + $pi = pathinfo($proposed); + $base = $pi['filename'] ?? $proposed; + $ext = isset($pi['extension']) && '' !== $pi['extension'] ? ('.'.$pi['extension']) : ''; + $i = 1; + do { + $candidate = $base.'_'.$i.$ext; + $i++; + } while ($findExistingByName($ann, $candidate)); + + return [null, $candidate, false]; + }; + + $createAttachment = function (string $filename, string $comment, int $size) use ($entity, $em) { + $att = (new CAnnouncementAttachment()) + ->setFilename($filename) + ->setPath(uniqid('announce_', true)) + ->setComment($comment) + ->setSize($size) + ->setAnnouncement($entity) + ->setParent($entity) + ->addCourseLink( + api_get_course_entity($this->destination_course_id), + api_get_session_entity(0), + api_get_group_entity() + ) + ; + $em->persist($att); + $em->flush(); + + return $att; + }; + + /** + * Search helper: try a list of absolute paths, then recursive search in a base dir by filename. + * Returns ['src'=>abs, 'filename'=>..., 'comment'=>..., 'size'=>int] or null. + */ + $resolveSourceFile = function (array $candidates, array $fallbackDirs, string $filename) { + // 1) direct candidates (absolute paths) + foreach ($candidates as $meta) { + if (!empty($meta['src']) && is_file($meta['src']) && is_readable($meta['src'])) { + $meta['filename'] = $meta['filename'] ?: basename($meta['src']); + $meta['size'] = (int) ($meta['size'] ?: (filesize($meta['src']) ?: 0)); + + return $meta; + } + } + + // 2) recursive search by filename inside fallback dirs + $filename = trim($filename); + if ('' !== $filename) { + foreach ($fallbackDirs as $base) { + $base = rtrim($base, '/').'/'; + if (!is_dir($base)) { + continue; + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $f) { + if ($f->isFile() && $f->getFilename() === $filename) { + return [ + 'src' => $f->getRealPath(), + 'filename' => $filename, + 'comment' => (string) ($candidates[0]['comment'] ?? ''), + 'size' => (int) ($candidates[0]['size'] ?? (filesize($f->getRealPath()) ?: 0)), + ]; + } + } + } + } + + return null; + }; + + $storeBinaryFromPath = function ( + CAnnouncementAttachment $target, + string $absPath + ) use ($attachRepo): void { + // This exists in your ResourceRepository + $attachRepo->addFileFromPath($target, $target->getFilename(), $absPath, true); + }; + + // ---------------------- COPY MODE (course->course) ---------------------- if ($copyMode) { $srcAttachmentIds = []; - if (!empty($a->attachment_source_id)) { $srcAttachmentIds[] = (int)$a->attachment_source_id; } - if (!empty($a->attachment_source_ids) && is_array($a->attachment_source_ids)) { - foreach ($a->attachment_source_ids as $sid) { $sid = (int)$sid; if ($sid > 0) $srcAttachmentIds[] = $sid; } + + if (!empty($a->attachment_source_id)) { + $srcAttachmentIds[] = (int) $a->attachment_source_id; + } + if (!empty($a->attachment_source_ids) && \is_array($a->attachment_source_ids)) { + foreach ($a->attachment_source_ids as $sid) { + $sid = (int) $sid; + if ($sid > 0) { + $srcAttachmentIds[] = $sid; + } + } } if (empty($srcAttachmentIds) && !empty($a->source_id)) { - $srcAnn = Container::getAnnouncementRepository()->find((int)$a->source_id); + $srcAnn = Container::getAnnouncementRepository()->find((int) $a->source_id); if ($srcAnn) { $srcAtts = Container::getAnnouncementAttachmentRepository()->findBy(['announcement' => $srcAnn]); - foreach ($srcAtts as $sa) { $srcAttachmentIds[] = (int)$sa->getIid(); } + foreach ($srcAtts as $sa) { + $srcAttachmentIds[] = (int) $sa->getIid(); + } } } - if (!empty($srcAttachmentIds)) { - $attRepo = Container::getAnnouncementAttachmentRepository(); + if (empty($srcAttachmentIds)) { + $this->dlog('restore_announcements: no source attachments found in COPY mode', [ + 'dst_announcement_id' => (int) $entity->getIid(), + ]); - foreach (array_unique($srcAttachmentIds) as $sid) { - /** @var CAnnouncementAttachment|null $srcAtt */ - $srcAtt = $attRepo->find($sid); - if (!$srcAtt) { continue; } + return; + } - $abs = $this->resourceFileAbsPathFromAnnouncementAttachment($srcAtt); - if (!$abs) { - $this->dlog('restore_announcements: source attachment file not readable', ['src_att_id' => $sid]); - continue; - } + $attRepo = Container::getAnnouncementAttachmentRepository(); - $filename = $srcAtt->getFilename() ?: basename($abs); - foreach ($entity->getAttachments() as $existingA) { - if ($existingA->getFilename() === $filename) { - if ($this->file_option === FILE_SKIP) { continue 2; } - if ($this->file_option === FILE_RENAME) { - $pi = pathinfo($filename); - $base = $pi['filename'] ?? $filename; - $ext = isset($pi['extension']) && $pi['extension'] !== '' ? ('.'.$pi['extension']) : ''; - $i = 1; $candidate = $filename; - $existingNames = array_map(fn($x) => $x->getFilename(), iterator_to_array($entity->getAttachments())); - while (in_array($candidate, $existingNames, true)) { $candidate = $base.'_'.$i.$ext; $i++; } - $filename = $candidate; - } - } - } + foreach (array_unique($srcAttachmentIds) as $sid) { + /** @var CAnnouncementAttachment|null $srcAtt */ + $srcAtt = $attRepo->find((int) $sid); + if (!$srcAtt) { + continue; + } - $newAtt = (new CAnnouncementAttachment()) - ->setFilename($filename) - ->setComment((string)$srcAtt->getComment()) - ->setSize((int)$srcAtt->getSize()) - ->setPath(uniqid('announce_', true)) - ->setAnnouncement($entity) - ->setParent($entity) - ->addCourseLink( - api_get_course_entity($this->destination_course_id), - api_get_session_entity(0), - api_get_group_entity() - ); + $abs = $this->resourceFileAbsPathFromAnnouncementAttachment($srcAtt); + if (!$abs) { + $this->dlog('restore_announcements: source attachment file not readable', ['src_att_id' => $sid]); - $em->persist($newAtt); - $em->flush(); + continue; + } - if (method_exists($attachRepo, 'addFileFromLocalPath')) { - $attachRepo->addFileFromLocalPath($newAtt, $abs); - } else { - $tmp = tempnam(sys_get_temp_dir(), 'ann_'); - @copy($abs, $tmp); - $_FILES['user_upload'] = [ - 'name' => $filename, - 'type' => function_exists('mime_content_type') ? (mime_content_type($tmp) ?: 'application/octet-stream') : 'application/octet-stream', - 'tmp_name' => $tmp, - 'error' => 0, - 'size' => filesize($tmp) ?: (int)$srcAtt->getSize(), - ]; - $attachRepo->addFileFromFileRequest($newAtt, 'user_upload'); - @unlink($tmp); - } + $proposed = $srcAtt->getFilename() ?: basename($abs); + [$targetAttachment, $finalName, $isOverwrite] = $decideTarget($proposed, $entity); - $this->dlog('restore_announcements: attachment copied from ResourceFile', [ - 'dst_announcement_id' => (int)$entity->getIid(), - 'filename' => $newAtt->getFilename(), - 'size' => $newAtt->getSize(), + if (null === $finalName) { + $this->dlog('restore_announcements: skipped due to FILE_SKIP policy', [ + 'src_att_id' => $sid, + 'filename' => $proposed, ]); + + continue; + } + + if (null === $targetAttachment) { + $targetAttachment = $createAttachment( + $finalName, + (string) $srcAtt->getComment(), + (int) ($srcAtt->getSize() ?: (is_file($abs) ? filesize($abs) : 0)) + ); + } else { + $targetAttachment + ->setComment((string) $srcAtt->getComment()) + ->setSize((int) ($srcAtt->getSize() ?: (is_file($abs) ? filesize($abs) : 0))) + ; + $em->persist($targetAttachment); + $em->flush(); } + + $storeBinaryFromPath($targetAttachment, $abs); + + $this->dlog('restore_announcements: attachment '.($isOverwrite ? 'overwritten' : 'copied').' from ResourceFile', [ + 'dst_announcement_id' => (int) $entity->getIid(), + 'filename' => $targetAttachment->getFilename(), + 'size' => $targetAttachment->getSize(), + ]); } + return; } - $meta = null; + $candidates = []; + + // Primary (from serialized record) if (!empty($a->attachment_path)) { - $src = rtrim($originPath, '/').'/'.$a->attachment_path; - if (is_file($src) && is_readable($src)) { - $meta = [ - 'src' => $src, - 'filename' => (string)($a->attachment_filename ?? basename($src)), - 'comment' => (string)($a->attachment_comment ?? ''), - 'size' => (int)($a->attachment_size ?? (filesize($src) ?: 0)), + $maybe = rtrim($originPath, '/').'/'.$a->attachment_path; + $filename = (string) ($a->attachment_filename ?? ''); + if (is_file($maybe)) { + $candidates[] = [ + 'src' => $maybe, + 'filename' => '' !== $filename ? $filename : basename($maybe), + 'comment' => (string) ($a->attachment_comment ?? ''), + 'size' => (int) ($a->attachment_size ?? (filesize($maybe) ?: 0)), ]; + } elseif (is_dir($maybe)) { + $try = '' !== $filename ? $maybe.'/'.$filename : ''; + if ('' !== $try && is_file($try)) { + $candidates[] = [ + 'src' => $try, + 'filename' => $filename, + 'comment' => (string) ($a->attachment_comment ?? ''), + 'size' => (int) ($a->attachment_size ?? (filesize($try) ?: 0)), + ]; + } else { + $files = []; + foreach (new FilesystemIterator($maybe, FilesystemIterator::SKIP_DOTS) as $f) { + if ($f->isFile()) { + $files[] = $f->getRealPath(); + } + } + if (1 === \count($files)) { + $one = $files[0]; + $candidates[] = [ + 'src' => $one, + 'filename' => '' !== $filename ? $filename : basename($one), + 'comment' => (string) ($a->attachment_comment ?? ''), + 'size' => (int) ($a->attachment_size ?? (filesize($one) ?: 0)), + ]; + } + } } } - if (!$meta && !empty($this->course->orig)) { - $table = \Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT); + + // Fallback DB snapshot + if (!empty($this->course->orig)) { + $table = Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT); $sql = 'SELECT path, comment, size, filename - FROM '.$table.' - WHERE c_id = '.$this->destination_course_id.' - AND announcement_id = '.(int)($a->source_id ?? 0); - $res = \Database::query($sql); - if ($row = \Database::fetch_object($res)) { - $src = rtrim($originPath, '/').'/'.$row->path; - if (is_file($src) && is_readable($src)) { - $meta = [ - 'src' => $src, - 'filename' => (string)$row->filename, - 'comment' => (string)$row->comment, - 'size' => (int)$row->size, + FROM '.$table.' + WHERE c_id = '.$this->destination_course_id.' + AND announcement_id = '.(int) ($a->source_id ?? 0); + $res = Database::query($sql); + while ($row = Database::fetch_object($res)) { + $base = rtrim($originPath, '/').'/'.$row->path; + $abs = null; + + if (is_file($base)) { + $abs = $base; + } elseif (is_dir($base)) { + $try = $base.'/'.$row->filename; + if (is_file($try)) { + $abs = $try; + } else { + $files = []; + foreach (new FilesystemIterator($base, FilesystemIterator::SKIP_DOTS) as $f) { + if ($f->isFile()) { + $files[] = $f->getRealPath(); + } + } + if (1 === \count($files)) { + $abs = $files[0]; + } + } + } + + if ($abs && is_readable($abs)) { + $candidates[] = [ + 'src' => $abs, + 'filename' => (string) $row->filename, + 'comment' => (string) $row->comment, + 'size' => (int) ($row->size ?: (filesize($abs) ?: 0)), ]; } } } - if (!$meta) { return; } - $attachment = (new CAnnouncementAttachment()) - ->setFilename($meta['filename']) - ->setPath(uniqid('announce_', true)) - ->setComment($meta['comment']) - ->setSize($meta['size']) - ->setAnnouncement($entity) - ->setParent($entity) - ->addCourseLink( - api_get_course_entity($this->destination_course_id), - api_get_session_entity(0), - api_get_group_entity() - ); + $fallbackDirs = [ + rtrim($this->course->backup_path ?? '', '/').'/upload/announcements', + rtrim($this->course->backup_path ?? '', '/').'/upload', + ]; - $em->persist($attachment); - $em->flush(); + $preferredFilename = (string) ($a->attachment_filename ?? ''); + if ('' === $preferredFilename && !empty($candidates)) { + $preferredFilename = (string) ($candidates[0]['filename'] ?? ''); + } - $tmp = tempnam(sys_get_temp_dir(), 'ann_'); - @copy($meta['src'], $tmp); - $_FILES['user_upload'] = [ - 'name' => $meta['filename'], - 'type' => function_exists('mime_content_type') ? (mime_content_type($tmp) ?: 'application/octet-stream') : 'application/octet-stream', - 'tmp_name' => $tmp, - 'error' => 0, - 'size' => filesize($tmp) ?: $meta['size'], - ]; - $attachRepo->addFileFromFileRequest($attachment, 'user_upload'); - @unlink($tmp); + $resolved = $resolveSourceFile($candidates, $fallbackDirs, $preferredFilename); + if (!$resolved) { + $this->dlog('restore_announcements: no ZIP attachments could be resolved', [ + 'dst_announcement_id' => (int) $entity->getIid(), + 'originPath' => $originPath, + 'hint' => 'Check upload/announcements and upload paths inside the package', + ]); + + return; + } - $this->dlog('restore_announcements: attachment stored (ZIP)', [ - 'announcement_id' => (int)$entity->getIid(), - 'filename' => $attachment->getFilename(), - 'size' => $attachment->getSize(), + $proposed = $resolved['filename'] ?: basename($resolved['src']); + [$targetAttachment, $finalName, $isOverwrite] = $decideTarget($proposed, $entity); + + if (null === $finalName) { + $this->dlog('restore_announcements: skipped due to FILE_SKIP policy (ZIP)', [ + 'filename' => $proposed, + ]); + + return; + } + + if (null === $targetAttachment) { + $targetAttachment = $createAttachment( + $finalName, + (string) $resolved['comment'], + (int) $resolved['size'] + ); + } else { + $targetAttachment + ->setComment((string) $resolved['comment']) + ->setSize((int) $resolved['size']) + ; + $em->persist($targetAttachment); + $em->flush(); + } + + $storeBinaryFromPath($targetAttachment, $resolved['src']); + + $this->dlog('restore_announcements: attachment '.($isOverwrite ? 'overwritten' : 'stored (ZIP)'), [ + 'announcement_id' => (int) $entity->getIid(), + 'filename' => $targetAttachment->getFilename(), + 'size' => $targetAttachment->getSize(), + 'src' => $resolved['src'], ]); } - public function restore_quizzes($session_id = 0, $respect_base_content = false) + /** + * Restore quizzes and their questions into the destination course. + * + * @param mixed $session_id + * @param mixed $respect_base_content + */ + public function restore_quizzes($session_id = 0, $respect_base_content = false): void { if (!$this->course->has_resources(RESOURCE_QUIZ)) { error_log('RESTORE_QUIZ: No quiz resources in backup.'); + return; } - $em = Database::getManager(); - $resources = $this->course->resources; - $courseEntity = api_get_course_entity($this->destination_course_id); - $sessionEntity = !empty($session_id) ? api_get_session_entity((int)$session_id) : api_get_session_entity(); + $em = Database::getManager(); + $resources = $this->course->resources; + $courseEntity = api_get_course_entity($this->destination_course_id); + $sessionEntity = !empty($session_id) ? api_get_session_entity((int) $session_id) : api_get_session_entity(); - $rewrite = function (?string $html) use ($courseEntity) { - if ($html === null || $html === false) return ''; - if (class_exists(ChamiloHelper::class) - && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { - try { - $backupRoot = $this->course->backup_path ?? ''; - return ChamiloHelper::rewriteLegacyCourseUrlsToAssets($html, $courseEntity, $backupRoot); - } catch (\Throwable $e) { - error_log('RESTORE_QUIZ: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); - return $html; - } + // Safe wrapper around rewriteHtmlForCourse + $rw = function (?string $html, string $dbgTag = 'QZ') use ($session_id) { + if (null === $html || false === $html || '' === $html) { + return ''; + } + + try { + return $this->rewriteHtmlForCourse((string) $html, (int) $session_id, $dbgTag); + } catch (Throwable $e) { + error_log('RESTORE_QUIZ: rewriteHtmlForCourse failed: '.$e->getMessage()); + + return (string) $html; } - return $html; }; + // Backward compat alias for legacy key if (empty($this->course->resources[RESOURCE_QUIZQUESTION]) && !empty($this->course->resources['Exercise_Question'])) { $this->course->resources[RESOURCE_QUIZQUESTION] = $this->course->resources['Exercise_Question']; @@ -2377,19 +2630,29 @@ public function restore_quizzes($session_id = 0, $respect_base_content = false) foreach ($resources[RESOURCE_QUIZ] as $id => $quizWrap) { $quiz = isset($quizWrap->obj) ? $quizWrap->obj : $quizWrap; - $description = $rewrite($quiz->description ?? ''); - $quiz->start_time = ($quiz->start_time === '0000-00-00 00:00:00') ? null : ($quiz->start_time ?? null); - $quiz->end_time = ($quiz->end_time === '0000-00-00 00:00:00') ? null : ($quiz->end_time ?? null); + // Rewrite HTML-bearing fields + $description = $rw($quiz->description ?? '', 'QZ.desc'); + $textFinished = $rw($quiz->text_when_finished ?? '', 'QZ.done.ok'); + $textFinishedKo = $rw($quiz->text_when_finished_failure ?? '', 'QZ.done.ko'); + + // Normalize dates + $quiz->start_time = (property_exists($quiz, 'start_time') && '0000-00-00 00:00:00' !== $quiz->start_time) + ? $quiz->start_time + : null; + $quiz->end_time = (property_exists($quiz, 'end_time') && '0000-00-00 00:00:00' !== $quiz->end_time) + ? $quiz->end_time + : null; global $_custom; if (!empty($_custom['exercises_clean_dates_when_restoring'])) { $quiz->start_time = null; - $quiz->end_time = null; + $quiz->end_time = null; } - if ((int)$id === -1) { + if (-1 === (int) $id) { $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = -1; error_log('RESTORE_QUIZ: Skipping virtual quiz (id=-1).'); + continue; } @@ -2411,49 +2674,51 @@ public function restore_quizzes($session_id = 0, $respect_base_content = false) ->setExpiredTime((int) $quiz->expired_time) ->setReviewAnswers((int) $quiz->review_answers) ->setRandomByCategory((int) $quiz->random_by_category) - ->setTextWhenFinished((string) ($quiz->text_when_finished ?? '')) - ->setTextWhenFinishedFailure((string) ($quiz->text_when_finished_failure ?? '')) + ->setTextWhenFinished($textFinished) + ->setTextWhenFinishedFailure($textFinishedKo) ->setDisplayCategoryName((int) ($quiz->display_category_name ?? 0)) ->setSaveCorrectAnswers(isset($quiz->save_correct_answers) ? (int) $quiz->save_correct_answers : 0) ->setPropagateNeg((int) $quiz->propagate_neg) ->setHideQuestionTitle((bool) ($quiz->hide_question_title ?? false)) ->setHideQuestionNumber((int) ($quiz->hide_question_number ?? 0)) - ->setStartTime(!empty($quiz->start_time) ? new \DateTime($quiz->start_time) : null) - ->setEndTime(!empty($quiz->end_time) ? new \DateTime($quiz->end_time) : null); + ->setStartTime(!empty($quiz->start_time) ? new DateTime($quiz->start_time) : null) + ->setEndTime(!empty($quiz->end_time) ? new DateTime($quiz->end_time) : null) + ; - if (isset($quiz->access_condition) && $quiz->access_condition !== '') { - $entity->setAccessCondition((string)$quiz->access_condition); + if (isset($quiz->access_condition) && '' !== $quiz->access_condition) { + $entity->setAccessCondition((string) $quiz->access_condition); } - if (isset($quiz->pass_percentage) && $quiz->pass_percentage !== '' && $quiz->pass_percentage !== null) { - $entity->setPassPercentage((int)$quiz->pass_percentage); + if (isset($quiz->pass_percentage) && '' !== $quiz->pass_percentage && null !== $quiz->pass_percentage) { + $entity->setPassPercentage((int) $quiz->pass_percentage); } - if (isset($quiz->question_selection_type) && $quiz->question_selection_type !== '' && $quiz->question_selection_type !== null) { - $entity->setQuestionSelectionType((int)$quiz->question_selection_type); + if (isset($quiz->question_selection_type) && '' !== $quiz->question_selection_type && null !== $quiz->question_selection_type) { + $entity->setQuestionSelectionType((int) $quiz->question_selection_type); } if ('true' === api_get_setting('exercise.allow_notification_setting_per_exercise')) { - $entity->setNotifications((string)($quiz->notifications ?? '')); + $entity->setNotifications((string) ($quiz->notifications ?? '')); } $em->persist($entity); $em->flush(); - $newQuizId = (int)$entity->getIid(); + $newQuizId = (int) $entity->getIid(); $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = $newQuizId; - $qCount = isset($quiz->question_ids) ? count((array)$quiz->question_ids) : 0; - error_log('RESTORE_QUIZ: Created quiz iid='.$newQuizId.' title="'.(string)$quiz->title.'" with '.$qCount.' question ids.'); + $qCount = isset($quiz->question_ids) ? \count((array) $quiz->question_ids) : 0; + error_log('RESTORE_QUIZ: Created quiz iid='.$newQuizId.' title="'.(string) $quiz->title.'" with '.$qCount.' question ids.'); $order = 0; if (!empty($quiz->question_ids)) { foreach ($quiz->question_ids as $index => $question_id) { - $qid = $this->restore_quiz_question($question_id); + $qid = $this->restore_quiz_question($question_id, (int) $session_id); if (!$qid) { error_log('RESTORE_QUIZ: restore_quiz_question returned 0 for src_question_id='.$question_id); + continue; } $question_order = !empty($quiz->question_orders[$index]) - ? (int)$quiz->question_orders[$index] + ? (int) $quiz->question_orders[$index] : $order; $order++; @@ -2461,30 +2726,33 @@ public function restore_quizzes($session_id = 0, $respect_base_content = false) $questionEntity = $em->getRepository(CQuizQuestion::class)->find($qid); if (!$questionEntity) { error_log('RESTORE_QUIZ: Question entity not found after insert. qid='.$qid); + continue; } $rel = (new CQuizRelQuestion()) ->setQuiz($entity) ->setQuestion($questionEntity) - ->setQuestionOrder($question_order); + ->setQuestionOrder($question_order) + ; $em->persist($rel); $em->flush(); } } else { - error_log('RESTORE_QUIZ: No questions bound to quiz src_id='.$id.' (title="'.(string)$quiz->title.'").'); + error_log('RESTORE_QUIZ: No questions bound to quiz src_id='.$id.' (title="'.(string) $quiz->title.'").'); } } } - /** * Restore quiz-questions. Returns new question IID. + * + * @param mixed $id */ - public function restore_quiz_question($id) + public function restore_quiz_question($id, int $session_id = 0) { - $em = Database::getManager(); + $em = Database::getManager(); $resources = $this->course->resources; if (empty($resources[RESOURCE_QUIZQUESTION]) && !empty($resources['Exercise_Question'])) { @@ -2495,35 +2763,37 @@ public function restore_quiz_question($id) /** @var object|null $question */ $question = $resources[RESOURCE_QUIZQUESTION][$id] ?? null; - if (!is_object($question)) { + if (!\is_object($question)) { error_log('RESTORE_QUESTION: Question not found in resources. src_id='.$id); + return 0; } if (method_exists($question, 'is_restored') && $question->is_restored()) { - return (int)$question->destination_id; + return (int) $question->destination_id; } $courseEntity = api_get_course_entity($this->destination_course_id); - $rewrite = function (?string $html) use ($courseEntity) { - if ($html === null || $html === false) return ''; - if (class_exists(ChamiloHelper::class) - && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { - try { - return ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)$html, $courseEntity, null); - } catch (\ArgumentCountError $e) { - return ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)$html, $courseEntity); - } catch (\Throwable $e) { - error_log('RESTORE_QUESTION: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); - return $html; - } + // Safe wrapper around rewriteHtmlForCourse + $rw = function (?string $html, string $dbgTag = 'QZ.Q') use ($session_id) { + if (null === $html || false === $html || '' === $html) { + return ''; + } + + try { + return $this->rewriteHtmlForCourse((string) $html, (int) $session_id, $dbgTag); + } catch (Throwable $e) { + error_log('RESTORE_QUESTION: rewriteHtmlForCourse failed: '.$e->getMessage()); + + return (string) $html; } - return $html; }; - $question->description = $rewrite($question->description ?? ''); - $question->question = $rewrite($question->question ?? ''); + // Rewrite statement & description + $question->description = $rw($question->description ?? '', 'QZ.Q.desc'); + $question->question = $rw($question->question ?? '', 'QZ.Q.text'); + // Picture mapping (kept as in your code) $imageNewId = ''; if (!empty($question->picture)) { if (isset($resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture])) { @@ -2533,7 +2803,7 @@ public function restore_quiz_question($id) } } - $qType = (int) ($question->quiz_type ?? $question->type); + $qType = (int) ($question->quiz_type ?? $question->type); $entity = (new CQuizQuestion()) ->setParent($courseEntity) ->addCourseLink($courseEntity, api_get_session_entity(), api_get_group_entity()) @@ -2544,71 +2814,80 @@ public function restore_quiz_question($id) ->setType($qType) ->setPicture($imageNewId) ->setLevel((int) ($question->level ?? 1)) - ->setExtra((string) ($question->extra ?? '')); + ->setExtra((string) ($question->extra ?? '')) + ; $em->persist($entity); $em->flush(); - $new_id = (int)$entity->getIid(); + $new_id = (int) $entity->getIid(); if (!$new_id) { error_log('RESTORE_QUESTION: Failed to obtain new question iid for src_id='.$id); + return 0; } - $answers = (array)($question->answers ?? []); - error_log('RESTORE_QUESTION: Creating question src_id='.$id.' dst_iid='.$new_id.' answers_count='.count($answers)); + $answers = (array) ($question->answers ?? []); + error_log('RESTORE_QUESTION: Creating question src_id='.$id.' dst_iid='.$new_id.' answers_count='.\count($answers)); - $isMatchingFamily = in_array($qType, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE], true); + $isMatchingFamily = \in_array($qType, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE], true); $correctMapSrcToDst = []; // dstAnsId => srcCorrectRef - $allSrcAnswersById = []; // srcAnsId => text + $allSrcAnswersById = []; // srcAnsId => text $dstAnswersByIdText = []; // dstAnsId => text if ($isMatchingFamily) { foreach ($answers as $a) { - $allSrcAnswersById[$a['id']] = $rewrite($a['answer'] ?? ''); + $allSrcAnswersById[$a['id']] = $rw($a['answer'] ?? '', 'QZ.Q.ans.all'); } } foreach ($answers as $a) { - $ansText = $rewrite($a['answer'] ?? ''); - $comment = $rewrite($a['comment'] ?? ''); + $ansText = $rw($a['answer'] ?? '', 'QZ.Q.ans'); + $comment = $rw($a['comment'] ?? '', 'QZ.Q.ans.cmt'); $ans = (new CQuizAnswer()) ->setQuestion($entity) - ->setAnswer((string)$ansText) - ->setComment((string)$comment) - ->setPonderation((float)($a['ponderation'] ?? 0)) - ->setPosition((int)($a['position'] ?? 0)) - ->setHotspotCoordinates(isset($a['hotspot_coordinates']) ? (string)$a['hotspot_coordinates'] : null) - ->setHotspotType(isset($a['hotspot_type']) ? (string)$a['hotspot_type'] : null); - - if (isset($a['correct']) && $a['correct'] !== '' && $a['correct'] !== null) { - $ans->setCorrect((int)$a['correct']); + ->setAnswer((string) $ansText) + ->setComment((string) $comment) + ->setPonderation((float) ($a['ponderation'] ?? 0)) + ->setPosition((int) ($a['position'] ?? 0)) + ->setHotspotCoordinates(isset($a['hotspot_coordinates']) ? (string) $a['hotspot_coordinates'] : null) + ->setHotspotType(isset($a['hotspot_type']) ? (string) $a['hotspot_type'] : null) + ; + + if (isset($a['correct']) && '' !== $a['correct'] && null !== $a['correct']) { + $ans->setCorrect((int) $a['correct']); } $em->persist($ans); $em->flush(); if ($isMatchingFamily) { - $correctMapSrcToDst[(int)$ans->getIid()] = $a['correct'] ?? null; - $dstAnswersByIdText[(int)$ans->getIid()] = $ansText; + $correctMapSrcToDst[(int) $ans->getIid()] = $a['correct'] ?? null; + $dstAnswersByIdText[(int) $ans->getIid()] = $ansText; } } if ($isMatchingFamily && $correctMapSrcToDst) { foreach ($entity->getAnswers() as $dstAns) { - $dstAid = (int)$dstAns->getIid(); + $dstAid = (int) $dstAns->getIid(); $srcRef = $correctMapSrcToDst[$dstAid] ?? null; - if ($srcRef === null) continue; + if (null === $srcRef) { + continue; + } if (isset($allSrcAnswersById[$srcRef])) { $needle = $allSrcAnswersById[$srcRef]; $newDst = null; foreach ($dstAnswersByIdText as $candId => $txt) { - if ($txt === $needle) { $newDst = $candId; break; } + if ($txt === $needle) { + $newDst = $candId; + + break; + } } - if ($newDst !== null) { - $dstAns->setCorrect((int)$newDst); + if (null !== $newDst) { + $dstAns->setCorrect((int) $newDst); $em->persist($dstAns); } } @@ -2616,23 +2895,25 @@ public function restore_quiz_question($id) $em->flush(); } - if (defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === $qType) { + if (\defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === $qType) { $newOptByOld = []; if (isset($question->question_options) && is_iterable($question->question_options)) { foreach ($question->question_options as $optWrap) { $opt = $optWrap->obj ?? $optWrap; + $optTitle = $rw($opt->name ?? '', 'QZ.Q.opt'); // rewrite option title too $optEntity = (new CQuizQuestionOption()) ->setQuestion($entity) - ->setTitle((string)$opt->name) - ->setPosition((int)$opt->position); + ->setTitle((string) $optTitle) + ->setPosition((int) $opt->position) + ; $em->persist($optEntity); $em->flush(); - $newOptByOld[$opt->id] = (int)$optEntity->getIid(); + $newOptByOld[$opt->id] = (int) $optEntity->getIid(); } foreach ($entity->getAnswers() as $dstAns) { $corr = $dstAns->getCorrect(); - if ($corr !== null && isset($newOptByOld[$corr])) { - $dstAns->setCorrect((int)$newOptByOld[$corr]); + if (null !== $corr && isset($newOptByOld[$corr])) { + $dstAns->setCorrect((int) $newOptByOld[$corr]); $em->persist($dstAns); } } @@ -2645,83 +2926,117 @@ public function restore_quiz_question($id) return $new_id; } - public function restore_surveys($sessionId = 0) + /** + * Restore surveys from backup into the destination course. + * + * @param mixed $sessionId + */ + public function restore_surveys($sessionId = 0): void { if (!$this->course->has_resources(RESOURCE_SURVEY)) { $this->debug && error_log('COURSE_DEBUG: restore_surveys: no survey resources in backup.'); + return; } - $em = Database::getManager(); - $surveyRepo = Container::getSurveyRepository(); - $courseEntity = api_get_course_entity($this->destination_course_id); - $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; + $em = Database::getManager(); + $surveyRepo = Container::getSurveyRepository(); - $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; - if ($backupRoot === '') { - $this->debug && error_log('COURSE_DEBUG: restore_surveys: backupRoot empty; URL rewriting may be partial.'); - } + /** @var CourseEntity $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); + + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null; + + $sid = (int) ($sessionEntity?->getId() ?? 0); + + $rewrite = function (?string $html, string $tag = '') use ($sid) { + if (null === $html || '' === $html) { + return ''; + } + + return $this->rewriteHtmlForCourse((string) $html, $sid, $tag); + }; $resources = $this->course->resources; foreach ($resources[RESOURCE_SURVEY] as $legacySurveyId => $surveyObj) { try { - $code = (string)($surveyObj->code ?? ''); - $lang = (string)($surveyObj->lang ?? ''); + $code = (string) ($surveyObj->code ?? ''); + $lang = (string) ($surveyObj->lang ?? ''); - $title = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->title ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->title ?? ''); - $subtitle = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->subtitle ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->subtitle ?? ''); - $intro = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->intro ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->intro ?? ''); - $surveyThanks = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->surveythanks ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->surveythanks ?? ''); + $title = $rewrite($surveyObj->title ?? '', ':survey.title'); + $subtitle = $rewrite($surveyObj->subtitle ?? '', ':survey.subtitle'); + $intro = $rewrite($surveyObj->intro ?? '', ':survey.intro'); + $surveyThanks = $rewrite($surveyObj->surveythanks ?? '', ':survey.thanks'); $onePerPage = !empty($surveyObj->one_question_per_page); - $shuffle = isset($surveyObj->shuffle) ? (bool)$surveyObj->shuffle : (!empty($surveyObj->suffle)); - $anonymous = (string)((int)($surveyObj->anonymous ?? 0)); + $shuffle = isset($surveyObj->shuffle) ? (bool) $surveyObj->shuffle : (!empty($surveyObj->suffle)); + $anonymous = (string) ((int) ($surveyObj->anonymous ?? 0)); + + try { + $creationDate = !empty($surveyObj->creation_date) ? new DateTime((string) $surveyObj->creation_date) : new DateTime(); + } catch (Throwable) { + $creationDate = new DateTime(); + } - try { $creationDate = !empty($surveyObj->creation_date) ? new \DateTime((string)$surveyObj->creation_date) : new \DateTime(); } catch (\Throwable) { $creationDate = new \DateTime(); } - try { $availFrom = !empty($surveyObj->avail_from) ? new \DateTime((string)$surveyObj->avail_from) : null; } catch (\Throwable) { $availFrom = null; } - try { $availTill = !empty($surveyObj->avail_till) ? new \DateTime((string)$surveyObj->avail_till) : null; } catch (\Throwable) { $availTill = null; } + try { + $availFrom = !empty($surveyObj->avail_from) ? new DateTime((string) $surveyObj->avail_from) : null; + } catch (Throwable) { + $availFrom = null; + } + + try { + $availTill = !empty($surveyObj->avail_till) ? new DateTime((string) $surveyObj->avail_till) : null; + } catch (Throwable) { + $availTill = null; + } - $visibleResults = isset($surveyObj->visible_results) ? (int)$surveyObj->visible_results : null; - $displayQuestionNumber = isset($surveyObj->display_question_number) ? (bool)$surveyObj->display_question_number : true; + $visibleResults = isset($surveyObj->visible_results) ? (int) $surveyObj->visible_results : null; + $displayQuestionNumber = isset($surveyObj->display_question_number) ? (bool) $surveyObj->display_question_number : true; $existing = null; + try { if (method_exists($surveyRepo, 'findOneByCodeAndLangInCourse')) { $existing = $surveyRepo->findOneByCodeAndLangInCourse($courseEntity, $code, $lang); } else { $existing = $surveyRepo->findOneBy(['code' => $code, 'lang' => $lang]); } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->debug && error_log('COURSE_DEBUG: restore_surveys: duplicate check skipped: '.$e->getMessage()); } if ($existing instanceof CSurvey) { switch ($this->file_option) { case FILE_SKIP: - $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int)$existing->getIid(); + $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int) $existing->getIid(); $this->debug && error_log("COURSE_DEBUG: restore_surveys: survey exists code='$code' (skip)."); + continue 2; case FILE_RENAME: - $base = $code.'_'; - $i = 1; - $try = $base.$i; + $base = '' !== $code ? $code.'_' : 'survey_'; + $i = 1; + $try = $base.$i; while (!$this->is_survey_code_available($try)) { $try = $base.(++$i); } $code = $try; $this->debug && error_log("COURSE_DEBUG: restore_surveys: renaming to '$code'."); + break; case FILE_OVERWRITE: - \SurveyManager::deleteSurvey($existing); + SurveyManager::deleteSurvey($existing); $em->flush(); - $this->debug && error_log("COURSE_DEBUG: restore_surveys: existing survey deleted (overwrite)."); + $this->debug && error_log('COURSE_DEBUG: restore_surveys: existing survey deleted (overwrite).'); + break; default: - $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int)$existing->getIid(); + $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int) $existing->getIid(); + continue 2; } } @@ -2735,24 +3050,27 @@ public function restore_surveys($sessionId = 0) ->setLang($lang) ->setAvailFrom($availFrom) ->setAvailTill($availTill) - ->setIsShared((string)($surveyObj->is_shared ?? '0')) - ->setTemplate((string)($surveyObj->template ?? 'template')) + ->setIsShared((string) ($surveyObj->is_shared ?? '0')) + ->setTemplate((string) ($surveyObj->template ?? 'template')) ->setIntro($intro) ->setSurveythanks($surveyThanks) ->setCreationDate($creationDate) ->setInvited(0) ->setAnswered(0) - ->setInviteMail((string)($surveyObj->invite_mail ?? '')) - ->setReminderMail((string)($surveyObj->reminder_mail ?? '')) + ->setInviteMail((string) ($surveyObj->invite_mail ?? '')) + ->setReminderMail((string) ($surveyObj->reminder_mail ?? '')) ->setOneQuestionPerPage($onePerPage) ->setShuffle($shuffle) ->setAnonymous($anonymous) - ->setDisplayQuestionNumber($displayQuestionNumber); + ->setDisplayQuestionNumber($displayQuestionNumber) + ; if (method_exists($newSurvey, 'setParent')) { $newSurvey->setParent($courseEntity); } - $newSurvey->addCourseLink($courseEntity, $sessionEntity); + if (method_exists($newSurvey, 'addCourseLink')) { + $newSurvey->addCourseLink($courseEntity, $sessionEntity); + } if (method_exists($surveyRepo, 'create')) { $surveyRepo->create($newSurvey); @@ -2761,65 +3079,80 @@ public function restore_surveys($sessionId = 0) $em->flush(); } - $newId = (int)$newSurvey->getIid(); + if (null !== $visibleResults && method_exists($newSurvey, 'setVisibleResults')) { + $newSurvey->setVisibleResults($visibleResults); + $em->flush(); + } + + $newId = (int) $newSurvey->getIid(); $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = $newId; - // --- Restore questions --- - $questionIds = is_array($surveyObj->question_ids ?? null) ? $surveyObj->question_ids : []; + // Restore questions + $questionIds = \is_array($surveyObj->question_ids ?? null) ? $surveyObj->question_ids : []; if (empty($questionIds) && !empty($resources[RESOURCE_SURVEYQUESTION])) { foreach ($resources[RESOURCE_SURVEYQUESTION] as $qid => $qWrap) { - $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; - if ((int)($q->survey_id ?? 0) === (int)$legacySurveyId) { - $questionIds[] = (int)$qid; + $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; + if ((int) ($q->survey_id ?? 0) === (int) $legacySurveyId) { + $questionIds[] = (int) $qid; } } } foreach ($questionIds as $legacyQid) { - $this->restore_survey_question((int)$legacyQid, $newId); + $this->restore_survey_question((int) $legacyQid, $newId, $sid); } - $this->debug && error_log("COURSE_DEBUG: restore_surveys: created survey iid={$newId}, questions=".count($questionIds)); - } catch (\Throwable $e) { + $this->debug && error_log("COURSE_DEBUG: restore_surveys: created survey iid={$newId}, questions=".\count($questionIds)); + } catch (Throwable $e) { error_log('COURSE_DEBUG: restore_surveys: failed: '.$e->getMessage()); } } } - /** - * Restore survey-questions (legacy signature). $survey_id is the NEW iid. + * Restore survey-questions. $survey_id is the NEW iid. + * + * @param mixed $id + * @param mixed $survey_id */ - public function restore_survey_question($id, $survey_id) + public function restore_survey_question($id, $survey_id, ?int $sid = null) { $resources = $this->course->resources; - $qWrap = $resources[RESOURCE_SURVEYQUESTION][$id] ?? null; + $qWrap = $resources[RESOURCE_SURVEYQUESTION][$id] ?? null; - if (!$qWrap || !is_object($qWrap)) { + if (!$qWrap || !\is_object($qWrap)) { $this->debug && error_log("COURSE_DEBUG: restore_survey_question: legacy question $id not found."); + return 0; } if (method_exists($qWrap, 'is_restored') && $qWrap->is_restored()) { return $qWrap->destination_id; } - $surveyRepo = Container::getSurveyRepository(); - $em = Database::getManager(); - $courseEntity = api_get_course_entity($this->destination_course_id); - - $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + $surveyRepo = Container::getSurveyRepository(); + $em = Database::getManager(); - $survey = $surveyRepo->find((int)$survey_id); + $survey = $surveyRepo->find((int) $survey_id); if (!$survey instanceof CSurvey) { $this->debug && error_log("COURSE_DEBUG: restore_survey_question: target survey $survey_id not found."); + return 0; } - $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; + $sid = (int) ($sid ?? api_get_session_id()); + + $rewrite = function (?string $html, string $tag = '') use ($sid) { + if (null === $html || '' === $html) { + return ''; + } + + return $this->rewriteHtmlForCourse((string) $html, $sid, $tag); + }; + + $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; - // Rewrite HTML - $questionText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($q->survey_question ?? ''), $courseEntity, $backupRoot) ?? (string)($q->survey_question ?? ''); - $commentText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($q->survey_question_comment ?? ''), $courseEntity, $backupRoot) ?? (string)($q->survey_question_comment ?? ''); + $questionText = $rewrite($q->survey_question ?? '', ':survey.q'); + $commentText = $rewrite($q->survey_question_comment ?? '', ':survey.qc'); try { $question = new CSurveyQuestion(); @@ -2827,21 +3160,22 @@ public function restore_survey_question($id, $survey_id) ->setSurvey($survey) ->setSurveyQuestion($questionText) ->setSurveyQuestionComment($commentText) - ->setType((string)($q->survey_question_type ?? $q->type ?? 'open')) - ->setDisplay((string)($q->display ?? 'vertical')) - ->setSort((int)($q->sort ?? 0)); + ->setType((string) ($q->survey_question_type ?? $q->type ?? 'open')) + ->setDisplay((string) ($q->display ?? 'vertical')) + ->setSort((int) ($q->sort ?? 0)) + ; if (isset($q->shared_question_id) && method_exists($question, 'setSharedQuestionId')) { - $question->setSharedQuestionId((int)$q->shared_question_id); + $question->setSharedQuestionId((int) $q->shared_question_id); } if (isset($q->max_value) && method_exists($question, 'setMaxValue')) { - $question->setMaxValue((int)$q->max_value); + $question->setMaxValue((int) $q->max_value); } if (isset($q->is_required)) { if (method_exists($question, 'setIsMandatory')) { - $question->setIsMandatory((bool)$q->is_required); + $question->setIsMandatory((bool) $q->is_required); } elseif (method_exists($question, 'setIsRequired')) { - $question->setIsRequired((bool)$q->is_required); + $question->setIsRequired((bool) $q->is_required); } } @@ -2849,11 +3183,11 @@ public function restore_survey_question($id, $survey_id) $em->flush(); // Options (value NOT NULL: default to 0 if missing) - $answers = is_array($q->answers ?? null) ? $q->answers : []; + $answers = \is_array($q->answers ?? null) ? $q->answers : []; foreach ($answers as $idx => $answer) { - $optText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($answer['option_text'] ?? ''), $courseEntity, $backupRoot) ?? (string)($answer['option_text'] ?? ''); - $value = isset($answer['value']) && $answer['value'] !== null ? (int)$answer['value'] : 0; - $sort = (int)($answer['sort'] ?? ($idx + 1)); + $optText = $rewrite($answer['option_text'] ?? '', ':survey.opt'); + $value = isset($answer['value']) && null !== $answer['value'] ? (int) $answer['value'] : 0; + $sort = (int) ($answer['sort'] ?? ($idx + 1)); $opt = new CSurveyQuestionOption(); $opt @@ -2861,45 +3195,28 @@ public function restore_survey_question($id, $survey_id) ->setQuestion($question) ->setOptionText($optText) ->setSort($sort) - ->setValue($value); + ->setValue($value) + ; $em->persist($opt); } $em->flush(); - $this->course->resources[RESOURCE_SURVEYQUESTION][$id]->destination_id = (int)$question->getIid(); + $this->course->resources[RESOURCE_SURVEYQUESTION][$id]->destination_id = (int) $question->getIid(); - return (int)$question->getIid(); - } catch (\Throwable $e) { + return (int) $question->getIid(); + } catch (Throwable $e) { error_log('COURSE_DEBUG: restore_survey_question: failed: '.$e->getMessage()); - return 0; - } - } - - public function is_survey_code_available($survey_code) - { - $survey_code = (string)$survey_code; - $surveyRepo = Container::getSurveyRepository(); - - try { - $hit = $surveyRepo->findOneBy(['code' => $survey_code]); - return $hit ? false : true; - } catch (\Throwable $e) { - $this->debug && error_log('COURSE_DEBUG: is_survey_code_available: fallback failed: '.$e->getMessage()); - return true; + return 0; } } - /** - * @param int $sessionId - * @param bool $baseContent - */ public function restore_learnpath_category(int $sessionId = 0, bool $baseContent = false): void { $reuseExisting = false; - if (isset($this->tool_copy_settings['learnpath_category']['reuse_existing']) && - true === $this->tool_copy_settings['learnpath_category']['reuse_existing']) { + if (isset($this->tool_copy_settings['learnpath_category']['reuse_existing']) + && true === $this->tool_copy_settings['learnpath_category']['reuse_existing']) { $reuseExisting = true; } @@ -2914,13 +3231,12 @@ public function restore_learnpath_category(int $sessionId = 0, bool $baseContent foreach ($resources[RESOURCE_LEARNPATH_CATEGORY] as $id => $item) { /** @var CLpCategory|null $lpCategory */ $lpCategory = $item->object; - if (!$lpCategory) { continue; } - $title = trim($lpCategory->getTitle()); - if ($title === '') { + $title = trim((string) $lpCategory->getTitle()); + if ('' === $title) { continue; } @@ -2944,7 +3260,6 @@ public function restore_learnpath_category(int $sessionId = 0, bool $baseContent 'c_id' => $this->destination_course_id, 'name' => $title, ]; - $categoryId = (int) learnpath::createCategory($values); } @@ -2954,131 +3269,6 @@ public function restore_learnpath_category(int $sessionId = 0, bool $baseContent } } - /** - * Zip a SCORM folder (must contain imsmanifest.xml) into a temp ZIP. - * Returns absolute path to the temp ZIP or null on error. - */ - private function zipScormFolder(string $folderAbs): ?string - { - $folderAbs = rtrim($folderAbs, '/'); - $manifest = $folderAbs.'/imsmanifest.xml'; - if (!is_file($manifest)) { - error_log("SCORM ZIPPER: 'imsmanifest.xml' not found in folder: $folderAbs"); - return null; - } - - $tmpZip = sys_get_temp_dir().'/scorm_'.uniqid('', true).'.zip'; - - try { - $zip = new ZipFile(); - // Put folder contents at the ZIP root – important for SCORM imports - $zip->addDirRecursive($folderAbs, ''); - $zip->saveAsFile($tmpZip); - $zip->close(); - } catch (\Throwable $e) { - error_log("SCORM ZIPPER: Failed to create temp zip: ".$e->getMessage()); - return null; - } - - if (!is_file($tmpZip) || filesize($tmpZip) === 0) { - @unlink($tmpZip); - error_log("SCORM ZIPPER: Temp zip is empty or missing: $tmpZip"); - return null; - } - - return $tmpZip; - } - - /** - * Find a SCORM package for a given LP. - * It returns ['zip' => , 'temp' => true if zip is temporary]. - * - * Search order: - * 1) resources[SCORM] entries bound to this LP (zip or path). - * - If 'path' is a folder containing imsmanifest.xml, it will be zipped on the fly. - * 2) Heuristics: scan typical folders for *.zip - * 3) Heuristics: scan backup recursively for an imsmanifest.xml, then zip that folder. - */ - private function findScormPackageForLp(int $srcLpId): array - { - $out = ['zip' => null, 'temp' => false]; - $base = rtrim($this->course->backup_path, '/'); - - // 1) Direct mapping from SCORM bucket - if (!empty($this->course->resources[RESOURCE_SCORM]) && is_array($this->course->resources[RESOURCE_SCORM])) { - foreach ($this->course->resources[RESOURCE_SCORM] as $sc) { - $src = isset($sc->source_lp_id) ? (int) $sc->source_lp_id : 0; - $dst = isset($sc->lp_id_dest) ? (int) $sc->lp_id_dest : 0; - $match = ($src && $src === $srcLpId); - - if ( - !$match && - $dst && - !empty($this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id) - ) { - $match = ($dst === (int) $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id); - } - if (!$match) { continue; } - - $cands = []; - if (!empty($sc->zip)) { $cands[] = $base.'/'.ltrim((string) $sc->zip, '/'); } - if (!empty($sc->path)) { $cands[] = $base.'/'.ltrim((string) $sc->path, '/'); } - - foreach ($cands as $abs) { - if (is_file($abs) && is_readable($abs)) { - $out['zip'] = $abs; - $out['temp'] = false; - return $out; - } - if (is_dir($abs) && is_readable($abs)) { - $tmp = $this->zipScormFolder($abs); - if ($tmp) { - $out['zip'] = $tmp; - $out['temp'] = true; - return $out; - } - } - } - } - } - - // 2) Heuristic: typical folders with *.zip - foreach (['/scorm','/document/scorm','/documents/scorm'] as $dir) { - $full = $base.$dir; - if (!is_dir($full)) { continue; } - $glob = glob($full.'/*.zip') ?: []; - if (!empty($glob)) { - $out['zip'] = $glob[0]; - $out['temp'] = false; - return $out; - } - } - - // 3) Heuristic: look for imsmanifest.xml anywhere, then zip that folder - $riiFlags = \FilesystemIterator::SKIP_DOTS; - try { - $rii = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($base, $riiFlags), - \RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($rii as $f) { - if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') { - $folder = $f->getPath(); - $tmp = $this->zipScormFolder($folder); - if ($tmp) { - $out['zip'] = $tmp; - $out['temp'] = true; - return $out; - } - } - } - } catch (\Throwable $e) { - error_log("SCORM FINDER: Recursive scan failed: ".$e->getMessage()); - } - - return $out; - } - /** * Restore SCORM ZIPs under Documents (Learning paths) for traceability. * Accepts real zips and on-the-fly temporary ones (temp will be deleted after upload). @@ -3087,27 +3277,35 @@ public function restore_scorm_documents(): void { $logp = 'RESTORE_SCORM_ZIP: '; - $getBucket = function(string $type) { - if (!empty($this->course->resources[$type]) && is_array($this->course->resources[$type])) { + $getBucket = function (string $type) { + if (!empty($this->course->resources[$type]) && \is_array($this->course->resources[$type])) { return $this->course->resources[$type]; } foreach ($this->course->resources ?? [] as $k => $v) { - if (is_string($k) && strtolower($k) === strtolower($type) && is_array($v)) { + if (\is_string($k) && strtolower($k) === strtolower($type) && \is_array($v)) { return $v; } } + return []; }; - /** @var \Chamilo\CourseBundle\Repository\CDocumentRepository $docRepo */ $docRepo = Container::getDocumentRepository(); - $em = Database::getManager(); + $em = Database::getManager(); $courseInfo = $this->destination_course_info; - if (empty($courseInfo) || empty($courseInfo['real_id'])) { error_log($logp.'missing courseInfo/real_id'); return; } + if (empty($courseInfo) || empty($courseInfo['real_id'])) { + error_log($logp.'missing courseInfo/real_id'); + + return; + } $courseEntity = api_get_course_entity((int) $courseInfo['real_id']); - if (!$courseEntity) { error_log($logp.'api_get_course_entity failed'); return; } + if (!$courseEntity) { + error_log($logp.'api_get_course_entity failed'); + + return; + } $sid = property_exists($this, 'current_session_id') ? (int) $this->current_session_id : 0; $session = api_get_session_entity($sid); @@ -3116,58 +3314,69 @@ public function restore_scorm_documents(): void // A) direct SCORM bucket $scormBucket = $getBucket(RESOURCE_SCORM); - foreach ($scormBucket as $sc) { $entries[] = $sc; } + foreach ($scormBucket as $sc) { + $entries[] = $sc; + } // B) also try LPs that are SCORM $lpBucket = $getBucket(RESOURCE_LEARNPATH); foreach ($lpBucket as $srcLpId => $lpObj) { - $lpType = (int)($lpObj->lp_type ?? $lpObj->type ?? 1); - if ($lpType === CLp::SCORM_TYPE) { - $entries[] = (object)[ - 'source_lp_id' => (int)$srcLpId, - 'lp_id_dest' => (int)($lpObj->destination_id ?? 0), + $lpType = (int) ($lpObj->lp_type ?? $lpObj->type ?? 1); + if (CLp::SCORM_TYPE === $lpType) { + $entries[] = (object) [ + 'source_lp_id' => (int) $srcLpId, + 'lp_id_dest' => (int) ($lpObj->destination_id ?? 0), ]; } } - error_log($logp.'entries='.count($entries)); - if (empty($entries)) { return; } + error_log($logp.'entries='.\count($entries)); + if (empty($entries)) { + return; + } $lpTop = $docRepo->ensureLearningPathSystemFolder($courseEntity, $session); foreach ($entries as $sc) { // Locate package (zip or folder → temp zip) - $srcLpId = (int)($sc->source_lp_id ?? 0); + $srcLpId = (int) ($sc->source_lp_id ?? 0); $pkg = $this->findScormPackageForLp($srcLpId); if (empty($pkg['zip'])) { error_log($logp.'No package (zip/folder) found for a SCORM entry'); + continue; } - $zipAbs = $pkg['zip']; - $zipTemp = (bool)$pkg['temp']; + $zipAbs = $pkg['zip']; + $zipTemp = (bool) $pkg['temp']; // Map LP title/dest for folder name - $lpId = 0; $lpTitle = 'Untitled'; + $lpId = 0; + $lpTitle = 'Untitled'; if (!empty($sc->lp_id_dest)) { $lpId = (int) $sc->lp_id_dest; } elseif ($srcLpId && !empty($lpBucket[$srcLpId]->destination_id)) { $lpId = (int) $lpBucket[$srcLpId]->destination_id; } $lpEntity = $lpId ? Container::getLpRepository()->find($lpId) : null; - if ($lpEntity) { $lpTitle = $lpEntity->getTitle() ?: $lpTitle; } + if ($lpEntity) { + $lpTitle = $lpEntity->getTitle() ?: $lpTitle; + } - $cleanTitle = preg_replace('/\s+/', ' ', trim(str_replace(['/', '\\'], '-', (string)$lpTitle))) ?: 'Untitled'; - $folderTitleBase = sprintf('SCORM - %d - %s', $lpId ?: 0, $cleanTitle); - $folderTitle = $folderTitleBase; + $cleanTitle = preg_replace('/\s+/', ' ', trim(str_replace(['/', '\\'], '-', (string) $lpTitle))) ?: 'Untitled'; + $folderTitleBase = \sprintf('SCORM - %d - %s', $lpId ?: 0, $cleanTitle); + $folderTitle = $folderTitleBase; $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle); if ($exists) { - if ($this->file_option === FILE_SKIP) { + if (FILE_SKIP === $this->file_option) { error_log($logp."Skip due to folder name collision: '$folderTitle'"); - if ($zipTemp) { @unlink($zipAbs); } + if ($zipTemp) { + @unlink($zipAbs); + } + continue; } - if ($this->file_option === FILE_RENAME) { + if (FILE_RENAME === $this->file_option) { $i = 1; do { $folderTitle = $folderTitleBase.' ('.$i.')'; @@ -3175,7 +3384,7 @@ public function restore_scorm_documents(): void $i++; } while ($exists); } - if ($this->file_option === FILE_OVERWRITE && $lpEntity) { + if (FILE_OVERWRITE === $this->file_option && $lpEntity) { $docRepo->purgeScormZip($courseEntity, $lpEntity); $em->flush(); } @@ -3183,373 +3392,565 @@ public function restore_scorm_documents(): void // Upload ZIP under Documents $uploaded = new UploadedFile( - $zipAbs, basename($zipAbs), 'application/zip', null, true + $zipAbs, + basename($zipAbs), + 'application/zip', + null, + true ); $lpFolder = $docRepo->ensureFolder( - $courseEntity, $lpTop, $folderTitle, - ResourceLink::VISIBILITY_DRAFT, $session + $courseEntity, + $lpTop, + $folderTitle, + ResourceLink::VISIBILITY_DRAFT, + $session ); $docRepo->createFileInFolder( - $courseEntity, $lpFolder, $uploaded, - sprintf('SCORM ZIP for LP #%d', $lpId), - ResourceLink::VISIBILITY_DRAFT, $session + $courseEntity, + $lpFolder, + $uploaded, + \sprintf('SCORM ZIP for LP #%d', $lpId), + ResourceLink::VISIBILITY_DRAFT, + $session ); $em->flush(); - if ($zipTemp) { @unlink($zipAbs); } + if ($zipTemp) { + @unlink($zipAbs); + } error_log($logp."ZIP stored under folder '$folderTitle'"); } } /** - * Restore learnpaths (SCORM-aware). - * For SCORM LPs, it accepts a real zip or zips a folder-on-the-fly if needed. - * This version adds strict checks, robust logging and a guaranteed fallback LP. + * Restore learnpaths with minimal dependencies hydration and robust path resolution. + * + * @param mixed $session_id + * @param mixed $respect_base_content + * @param mixed $destination_course_code */ - public function restore_learnpaths($session_id = 0, $respect_base_content = false, $destination_course_code = '') + public function restore_learnpaths($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void { - $logp = 'RESTORE_LP: '; + // 0) Ensure we have a resources snapshot (either internal or from the course) + $this->ensureDepsBagsFromSnapshot(); + $all = $this->getAllResources(); // <- uses snapshot if available + + $docBag = $all[RESOURCE_DOCUMENT] ?? []; + $quizBag = $all[RESOURCE_QUIZ] ?? []; + $linkBag = $all[RESOURCE_LINK] ?? []; + $survBag = $all[RESOURCE_SURVEY] ?? []; + $workBag = $all[RESOURCE_WORK] ?? []; + $forumB = $all['forum'] ?? []; + + $this->dlog('LP: deps (after ensure/snapshot)', [ + 'document' => \count($docBag), + 'quiz' => \count($quizBag), + 'link' => \count($linkBag), + 'student_publication' => \count($workBag), + 'survey' => \count($survBag), + 'forum' => \count($forumB), + ]); - // --- REQUIRED INITIALIZATION (avoid "Undefined variable $courseEntity") --- - $courseInfo = $this->destination_course_info ?? []; - $courseId = (int)($courseInfo['real_id'] ?? 0); - if ($courseId <= 0) { - error_log($logp.'Missing destination course id; aborting.'); - return; - } + // Quick exit if no LPs selected + $lpBag = $this->course->resources[RESOURCE_LEARNPATH] ?? []; + if (empty($lpBag)) { + $this->dlog('LP: nothing to restore (bag is empty).'); - $courseEntity = api_get_course_entity($courseId); - if (!$courseEntity) { - error_log($logp.'api_get_course_entity() returned null for id='.$courseId.'; aborting.'); return; } - // Session entity is optional - $session = $session_id ? api_get_session_entity((int)$session_id) : null; + // Full snapshot to lookup deps without forcing user selection + // Must be available BEFORE filtering in the import controller (controller already forwards it). + $all = $this->getAllResources(); + + // Map normalized resource types to bags (no extra validations) + $type2bags = [ + 'document' => ['document', RESOURCE_DOCUMENT], + 'quiz' => ['quiz', RESOURCE_QUIZ], + 'exercise' => ['quiz', RESOURCE_QUIZ], + 'link' => ['link', RESOURCE_LINK], + 'weblink' => ['link', RESOURCE_LINK], + 'url' => ['link', RESOURCE_LINK], + 'work' => ['works', RESOURCE_WORK], + 'student_publication' => ['works', RESOURCE_WORK], + 'survey' => ['survey', RESOURCE_SURVEY], + 'forum' => ['forum', 'forum'], + // scorm/sco not handled here + ]; - $em = Database::getManager(); - $lpRepo = Container::getLpRepository(); + // ID collectors per dependency kind + $need = [ + RESOURCE_DOCUMENT => [], + RESOURCE_QUIZ => [], + RESOURCE_LINK => [], + RESOURCE_WORK => [], + RESOURCE_SURVEY => [], + 'forum' => [], + ]; - /** - * Resolve a resource "bucket" by type (constant or string) and return [key, data]. - * - Normalizes common aliases (case-insensitive). - * - Keeps original bucket key so we can write back destination_id on the right slot. - */ - $getBucketWithKey = function (int|string $type) use ($logp) { - // Map constants to canonical strings - if (is_int($type)) { - $type = match ($type) { - defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : -1 => 'learnpath', - defined('RESOURCE_SCORM') ? RESOURCE_SCORM : -2 => 'scorm', - default => (string)$type, - }; + $takeId = static function ($v) { + if (null === $v || '' === $v) { + return null; } - // Common legacy aliases - $aliases = [ - 'learnpath' => ['learnpath','coursecopylearnpath','CourseCopyLearnpath','learning_path'], - 'scorm' => ['scorm','scormdocument','ScormDocument'], - ]; + return ctype_digit((string) $v) ? (int) $v : null; + }; + + // Collect deps from LP items + foreach ($lpBag as $srcLpId => $lpWrap) { + $items = \is_array($lpWrap->items ?? null) ? $lpWrap->items : []; + foreach ($items as $it) { + $itype = strtolower((string) ($it['item_type'] ?? '')); + $raw = $it['path'] ?? ($it['ref'] ?? ($it['identifierref'] ?? '')); + $id = $takeId($raw); - $want = strtolower((string)$type); - $wantedKeys = array_unique(array_merge([$type], $aliases[$want] ?? [])); + if (null === $id) { + continue; + } + if (!isset($type2bags[$itype])) { + continue; + } - $res = is_array($this->course->resources ?? null) ? $this->course->resources : []; - if (empty($res)) { - error_log($logp."resources array is empty or invalid"); - return [null, []]; + [, $bag] = $type2bags[$itype]; + $need[$bag][$id] = true; } + } + + // Collect deps from linked_resources (export helper) + foreach ($lpBag as $srcLpId => $lpWrap) { + $linked = \is_array($lpWrap->linked_resources ?? null) ? $lpWrap->linked_resources : []; + foreach ($linked as $k => $ids) { + // normalize key to a known bag with $type2bags + $kk = strtolower($k); + if (isset($type2bags[$kk])) { + [, $bag] = $type2bags[$kk]; + } else { + // sometimes exporter uses bag names directly (document/quiz/link/works/survey/forum) + $bag = $kk; + } + + if (!isset($need[$bag])) { + continue; + } + if (!\is_array($ids)) { + continue; + } - // 1) Exact match - foreach ($wantedKeys as $k) { - if (isset($res[$k]) && is_array($res[$k])) { - error_log($logp."bucket '". $type ."' found as '$k' (".count($res[$k]).")"); - return [$k, $res[$k]]; + foreach ($ids as $legacyId) { + $id = $takeId($legacyId); + if (null !== $id) { + $need[$bag][$id] = true; + } } } - // 2) Case-insensitive match - $lowerWanted = array_map('strtolower', $wantedKeys); - foreach ($res as $k => $v) { - if (is_string($k) && in_array(strtolower($k), $lowerWanted, true) && is_array($v)) { - error_log($logp."bucket '". $type ."' found as '$k' (".count($v).")"); - return [$k, $v]; + } + + // Build minimal bags from the snapshot using ONLY needed IDs + $filterBag = static function (array $sourceBag, array $idSet): array { + if (empty($idSet)) { + return []; + } + $out = []; + foreach ($idSet as $legacyId => $_) { + if (isset($sourceBag[$legacyId])) { + $out[$legacyId] = $sourceBag[$legacyId]; } } - error_log($logp."bucket '".(string)$type."' not found"); - return [null, []]; + return $out; }; - // Resolve learnpath bucket (returning its actual key to write back destination_id) - [$lpBucketKey, $lpBucket] = $getBucketWithKey(defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath'); - if (empty($lpBucket)) { - error_log($logp."No LPs to process"); - return; + // Inject minimal bags only if the selected set didn't include them. + if (!isset($this->course->resources[RESOURCE_DOCUMENT])) { + $src = $all[RESOURCE_DOCUMENT] ?? []; + $this->course->resources[RESOURCE_DOCUMENT] = $filterBag($src, $need[RESOURCE_DOCUMENT]); + } + if (!isset($this->course->resources[RESOURCE_QUIZ])) { + $src = $all[RESOURCE_QUIZ] ?? []; + $this->course->resources[RESOURCE_QUIZ] = $filterBag($src, $need[RESOURCE_QUIZ]); + if (!empty($this->course->resources[RESOURCE_QUIZ]) + && !isset($this->course->resources[RESOURCE_QUIZQUESTION])) { + $this->course->resources[RESOURCE_QUIZQUESTION] = + $all[RESOURCE_QUIZQUESTION] ?? ($all['Exercise_Question'] ?? []); + } + } + if (!isset($this->course->resources[RESOURCE_LINK])) { + $src = $all[RESOURCE_LINK] ?? []; + $this->course->resources[RESOURCE_LINK] = $filterBag($src, $need[RESOURCE_LINK]); + if (!isset($this->course->resources[RESOURCE_LINKCATEGORY]) && isset($all[RESOURCE_LINKCATEGORY])) { + $this->course->resources[RESOURCE_LINKCATEGORY] = $all[RESOURCE_LINKCATEGORY]; + } + } + if (!isset($this->course->resources[RESOURCE_WORK])) { + $src = $all[RESOURCE_WORK] ?? []; + $this->course->resources[RESOURCE_WORK] = $filterBag($src, $need[RESOURCE_WORK]); + } + if (!isset($this->course->resources[RESOURCE_SURVEY])) { + $src = $all[RESOURCE_SURVEY] ?? []; + $this->course->resources[RESOURCE_SURVEY] = $filterBag($src, $need[RESOURCE_SURVEY]); + if (!isset($this->course->resources[RESOURCE_SURVEYQUESTION]) && isset($all[RESOURCE_SURVEYQUESTION])) { + $this->course->resources[RESOURCE_SURVEYQUESTION] = $all[RESOURCE_SURVEYQUESTION]; + } + } + if (!isset($this->course->resources['forum'])) { + $src = $all['forum'] ?? []; + $this->course->resources['forum'] = $filterBag($src, $need['forum']); + // minimal forum support if LP points to forums + if (!empty($this->course->resources['forum'])) { + foreach (['Forum_Category', 'thread', 'post'] as $k) { + if (!isset($this->course->resources[$k]) && isset($all[$k])) { + $this->course->resources[$k] = $all[$k]; + } + } + } } - // Optional: resolve scorm bucket (may be used by other helpers) - [$_scormKey, $scormBucket] = $getBucketWithKey(defined('RESOURCE_SCORM') ? RESOURCE_SCORM : 'scorm'); - error_log($logp."LPs=".count($lpBucket).", SCORM entries=".count($scormBucket)); + $this->dlog('LP: minimal deps prepared', [ + 'document' => \count($this->course->resources[RESOURCE_DOCUMENT] ?? []), + 'quiz' => \count($this->course->resources[RESOURCE_QUIZ] ?? []), + 'link' => \count($this->course->resources[RESOURCE_LINK] ?? []), + 'student_publication' => \count($this->course->resources[RESOURCE_WORK] ?? []), + 'survey' => \count($this->course->resources[RESOURCE_SURVEY] ?? []), + 'forum' => \count($this->course->resources['forum'] ?? []), + ]); - foreach ($lpBucket as $srcLpId => $lpObj) { - $lpName = $lpObj->name ?? ($lpObj->title ?? ('LP '.$srcLpId)); - $lpType = (int)($lpObj->lp_type ?? $lpObj->type ?? 1); // 2 = SCORM - $encoding = $lpObj->default_encoding ?? 'UTF-8'; + // --- 3) Restore ONLY those minimal bags --- + if (!empty($this->course->resources[RESOURCE_DOCUMENT])) { + $this->restore_documents($session_id, false, $destination_course_code); + } + if (!empty($this->course->resources[RESOURCE_QUIZ])) { + $this->restore_quizzes($session_id, false); + } + if (!empty($this->course->resources[RESOURCE_LINK])) { + $this->restore_links($session_id); + } + if (!empty($this->course->resources[RESOURCE_WORK])) { + $this->restore_works($session_id); + } + if (!empty($this->course->resources[RESOURCE_SURVEY])) { + $this->restore_surveys($session_id); + } + if (!empty($this->course->resources['forum'])) { + $this->restore_forums($session_id); + } - error_log($logp."LP src=$srcLpId, name='". $lpName ."', type=".$lpType); + // --- 4) Create LP + items with resolved paths to new destination iids --- + $em = Database::getManager(); + $courseEnt = api_get_course_entity($this->destination_course_id); + $sessionEnt = api_get_session_entity((int) $session_id); + $lpRepo = Container::getLpRepository(); + $lpItemRepo = Container::getLpItemRepository(); + $docRepo = Container::getDocumentRepository(); - // ---- SCORM ---- - if ($lpType === CLp::SCORM_TYPE) { - $createdLpId = 0; - $zipAbs = null; - $zipTemp = false; + // Optional repos for title fallbacks + $quizRepo = method_exists(Container::class, 'getQuizRepository') ? Container::getQuizRepository() : null; + $linkRepo = method_exists(Container::class, 'getLinkRepository') ? Container::getLinkRepository() : null; + $forumRepo = method_exists(Container::class, 'getForumRepository') ? Container::getForumRepository() : null; + $surveyRepo = method_exists(Container::class, 'getSurveyRepository') ? Container::getSurveyRepository() : null; + $workRepo = method_exists(Container::class, 'getStudentPublicationRepository') ? Container::getStudentPublicationRepository() : null; - try { - // Find a real SCORM ZIP (or zip a folder on-the-fly) - $pkg = $this->findScormPackageForLp((int)$srcLpId); - $zipAbs = $pkg['zip'] ?? null; - $zipTemp = !empty($pkg['temp']); + $getDst = function (string $bag, $legacyId): int { + $wrap = $this->course->resources[$bag][$legacyId] ?? null; - if (!$zipAbs || !is_readable($zipAbs)) { - error_log($logp."SCORM LP src=$srcLpId: NO ZIP found/readable"); - } else { - error_log($logp."SCORM LP src=$srcLpId ZIP=".$zipAbs); - - // Try to resolve currentDir from the BACKUP (folder or ZIP) - $currentDir = ''; - $tmpExtractDir = ''; - $bp = (string) ($this->course->backup_path ?? ''); - - // Case A: backup_path is an extracted directory - if ($bp && is_dir($bp)) { - try { - $rii = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($bp, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($rii as $f) { - if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') { - $currentDir = $f->getPath(); - break; - } - } - } catch (\Throwable $e) { - error_log($logp.'Scan BACKUP dir failed: '.$e->getMessage()); - } - } + return $wrap && isset($wrap->destination_id) ? (int) $wrap->destination_id : 0; + }; - // Case B: backup_path is a ZIP under var/cache/course_backups - if (!$currentDir && $bp && is_file($bp) && preg_match('/\.zip$/i', $bp)) { - $tmpExtractDir = rtrim(sys_get_temp_dir(), '/').'/scorm_restore_'.uniqid('', true); - @mkdir($tmpExtractDir, 0777, true); - try { - $zf = new ZipFile(); - $zf->openFile($bp); - $zf->extractTo($tmpExtractDir); - $zf->close(); - - $rii = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($tmpExtractDir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($rii as $f) { - if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') { - $currentDir = $f->getPath(); - break; - } - } - } catch (\Throwable $e) { - error_log($logp.'TMP unzip failed: '.$e->getMessage()); - } - } + $findDocIidByTitle = function (string $title) use ($docRepo, $courseEnt, $sessionEnt): int { + if ('' === $title) { + return 0; + } - if ($currentDir) { - error_log($logp.'Resolved currentDir from BACKUP: '.$currentDir); - } else { - error_log($logp.'Could not resolve currentDir from backup; import_package will derive it'); - } + try { + $hit = $docRepo->findCourseResourceByTitle( + $title, + $courseEnt->getResourceNode(), + $courseEnt, + $sessionEnt, + api_get_group_entity() + ); - // Import in scorm class (import_manifest will create LP + items) - $sc = new \scorm(); - $fileInfo = ['tmp_name' => $zipAbs, 'name' => basename($zipAbs)]; + return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0; + } catch (Throwable $e) { + $this->dlog('LP: doc title lookup failed', ['title' => $title, 'err' => $e->getMessage()]); - $ok = $sc->import_package($fileInfo, $currentDir); + return 0; + } + }; - // Cleanup tmp if we extracted the backup ZIP - if ($tmpExtractDir && is_dir($tmpExtractDir)) { - $it = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($tmpExtractDir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ($it as $p) { - $p->isDir() ? @rmdir($p->getPathname()) : @unlink($p->getPathname()); - } - @rmdir($tmpExtractDir); - } + // Generic title finders (defensive: method_exists checks) + $findByTitle = [ + 'quiz' => function (string $title) use ($quizRepo, $courseEnt, $sessionEnt): int { + if (!$quizRepo || '' === $title) { + return 0; + } - if ($ok !== true) { - error_log($logp."import_package() returned false"); - } else { - if (empty($sc->manifestToString)) { - error_log($logp."manifestToString empty after import_package()"); - } else { - // Parse & import manifest (creates LP + items) - $sc->parse_manifest(); - - /** @var CLp|null $lp */ - $lp = $sc->import_manifest($courseId, 1, (int) $session_id); - if ($lp instanceof CLp) { - if (property_exists($lpObj, 'content_local')) { - $lp->setContentLocal((int) $lpObj->content_local); - } - if (property_exists($lpObj, 'content_maker')) { - $lp->setContentMaker((string) $lpObj->content_maker); - } - $lp->setDefaultEncoding((string) $encoding); - - $em->persist($lp); - $em->flush(); - - $createdLpId = (int)$lp->getIid(); - if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) { - $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = $createdLpId; - } - error_log($logp."SCORM LP created id=".$createdLpId." (via manifest)"); - } else { - error_log($logp."import_manifest() returned NULL"); - } - } - } + try { + $hit = method_exists($quizRepo, 'findOneByTitleInCourse') + ? $quizRepo->findOneByTitleInCourse($title, $courseEnt, $sessionEnt) + : $quizRepo->findOneBy(['title' => $title]); + + return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0; + } catch (Throwable $e) { + return 0; + } + }, + 'link' => function (string $title) use ($linkRepo, $courseEnt): int { + if (!$linkRepo || '' === $title) { + return 0; + } + + try { + $hit = method_exists($linkRepo, 'findOneByTitleInCourse') + ? $linkRepo->findOneByTitleInCourse($title, $courseEnt, null) + : $linkRepo->findOneBy(['title' => $title]); + + return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0; + } catch (Throwable $e) { + return 0; + } + }, + 'forum' => function (string $title) use ($forumRepo, $courseEnt): int { + if (!$forumRepo || '' === $title) { + return 0; + } + + try { + $hit = method_exists($forumRepo, 'findOneByTitleInCourse') + ? $forumRepo->findOneByTitleInCourse($title, $courseEnt, null) + : $forumRepo->findOneBy(['forum_title' => $title]) ?? $forumRepo->findOneBy(['title' => $title]); + + return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0; + } catch (Throwable $e) { + return 0; + } + }, + 'survey' => function (string $title) use ($surveyRepo, $courseEnt): int { + if (!$surveyRepo || '' === $title) { + return 0; + } + + try { + $hit = method_exists($surveyRepo, 'findOneByTitleInCourse') + ? $surveyRepo->findOneByTitleInCourse($title, $courseEnt, null) + : $surveyRepo->findOneBy(['title' => $title]); + + return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0; + } catch (Throwable $e) { + return 0; + } + }, + 'work' => function (string $title) use ($workRepo, $courseEnt): int { + if (!$workRepo || '' === $title) { + return 0; + } + + try { + $hit = method_exists($workRepo, 'findOneByTitleInCourse') + ? $workRepo->findOneByTitleInCourse($title, $courseEnt, null) + : $workRepo->findOneBy(['title' => $title]); + + return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0; + } catch (Throwable $e) { + return 0; + } + }, + ]; + + $resolvePath = function (array $it) use ($getDst, $findDocIidByTitle, $findByTitle): string { + $itype = strtolower((string) ($it['item_type'] ?? '')); + $raw = $it['path'] ?? ($it['ref'] ?? ($it['identifierref'] ?? '')); + $title = trim((string) ($it['title'] ?? '')); + + switch ($itype) { + case 'document': + if (ctype_digit((string) $raw)) { + $nid = $getDst(RESOURCE_DOCUMENT, (int) $raw); + + return $nid ? (string) $nid : ''; } - } catch (\Throwable $e) { - error_log($logp.'EXCEPTION: '.$e->getMessage()); - } finally { - if (empty($createdLpId)) { - $lp = (new CLp()) - ->setLpType(CLp::SCORM_TYPE) - ->setTitle((string) $lpName) - ->setDefaultEncoding((string) $encoding) - ->setJsLib('scorm_api.php') - ->setUseMaxScore(1) - ->setParent($courseEntity); - - if (method_exists($lp, 'addCourseLink')) { - // pass session only if available - $lp->addCourseLink($courseEntity, $session ?: null); - } + if (\is_string($raw) && str_starts_with((string) $raw, 'document/')) { + return (string) $raw; + } + $maybe = $findDocIidByTitle('' !== $title ? $title : (string) $raw); - $lpRepo->createLp($lp); - $em->flush(); + return $maybe ? (string) $maybe : ''; - $createdLpId = (int) $lp->getIid(); - if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) { - $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = $createdLpId; - } - error_log($logp."SCORM LP created id=".$createdLpId." (FALLBACK)"); + case 'quiz': + case 'exercise': + $id = ctype_digit((string) $raw) ? (int) $raw : 0; + $nid = $id ? $getDst(RESOURCE_QUIZ, $id) : 0; + if ($nid) { + return (string) $nid; } + $nid = $findByTitle['quiz']('' !== $title ? $title : (string) $raw); - // Remove temp ZIP if we created it in findScormPackageForLp() - if (!empty($zipTemp) && !empty($zipAbs) && is_file($zipAbs)) { - @unlink($zipAbs); + return $nid ? (string) $nid : ''; + + case 'link': + case 'weblink': + case 'url': + $id = ctype_digit((string) $raw) ? (int) $raw : 0; + $nid = $id ? $getDst(RESOURCE_LINK, $id) : 0; + if ($nid) { + return (string) $nid; } - } + $nid = $findByTitle['link']('' !== $title ? $title : (string) $raw); + + return $nid ? (string) $nid : ''; + + case 'work': + case 'student_publication': + $id = ctype_digit((string) $raw) ? (int) $raw : 0; + $nid = $id ? $getDst(RESOURCE_WORK, $id) : 0; + if ($nid) { + return (string) $nid; + } + $nid = $findByTitle['work']('' !== $title ? $title : (string) $raw); + + return $nid ? (string) $nid : ''; + + case 'survey': + $id = ctype_digit((string) $raw) ? (int) $raw : 0; + $nid = $id ? $getDst(RESOURCE_SURVEY, $id) : 0; + if ($nid) { + return (string) $nid; + } + $nid = $findByTitle['survey']('' !== $title ? $title : (string) $raw); - continue; // next LP + return $nid ? (string) $nid : ''; + + case 'forum': + $id = ctype_digit((string) $raw) ? (int) $raw : 0; + $nid = $id ? $getDst('forum', $id) : 0; + if ($nid) { + return (string) $nid; + } + $nid = $findByTitle['forum']('' !== $title ? $title : (string) $raw); + + return $nid ? (string) $nid : ''; + + default: + // keep whatever was exported + return (string) $raw; } + }; + + foreach ($lpBag as $srcLpId => $lpWrap) { + $title = (string) ($lpWrap->name ?? $lpWrap->title ?? ('LP '.$srcLpId)); + $desc = (string) ($lpWrap->description ?? ''); + $lpType = (int) ($lpWrap->lp_type ?? $lpWrap->type ?? 1); - // ---- Non-SCORM ---- $lp = (new CLp()) - ->setLpType(CLp::LP_TYPE) - ->setTitle((string) $lpName) - ->setDefaultEncoding((string) $encoding) - ->setJsLib('scorm_api.php') - ->setUseMaxScore(1) - ->setParent($courseEntity); + ->setLpType($lpType) + ->setTitle($title) + ->setParent($courseEnt) + ; if (method_exists($lp, 'addCourseLink')) { - $lp->addCourseLink($courseEntity, $session ?: null); + $lp->addCourseLink($courseEnt, $sessionEnt); + } + if (method_exists($lp, 'setDescription')) { + $lp->setDescription($desc); } $lpRepo->createLp($lp); $em->flush(); - error_log($logp."Standard LP created id=".$lp->getIid()); - - if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) { - $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = (int) $lp->getIid(); - } - - // Manual items (only for non-SCORM if present in backup) - if (!empty($lpObj->items) && is_array($lpObj->items)) { - $lpItemRepo = Container::getLpItemRepository(); - $rootItem = $lpItemRepo->getRootItem($lp->getIid()); - $parents = [0 => $rootItem]; - - foreach ($lpObj->items as $it) { - $level = (int) ($it['level'] ?? 0); - if (!isset($parents[$level])) { $parents[$level] = end($parents); } - $parentEntity = $parents[$level] ?? $rootItem; - - $lpItem = (new CLpItem()) - ->setTitle((string) ($it['title'] ?? '')) - ->setItemType((string) ($it['item_type'] ?? 'dir')) - ->setRef((string) ($it['identifier'] ?? '')) - ->setPath((string) ($it['path'] ?? '')) - ->setMinScore(0) - ->setMaxScore((int) ($it['max_score'] ?? 100)) - ->setPrerequisite((string) ($it['prerequisites'] ?? '')) - ->setLaunchData((string) ($it['datafromlms'] ?? '')) - ->setParameters((string) ($it['parameters'] ?? '')) - ->setLp($lp) - ->setParent($parentEntity); - - $lpItemRepo->create($lpItem); - $parents[$level+1] = $lpItem; + + $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id = (int) $lp->getIid(); + + $root = $lpItemRepo->getRootItem($lp->getIid()); + $parents = [0 => $root]; + $items = \is_array($lpWrap->items ?? null) ? $lpWrap->items : []; + $order = 0; + + foreach ($items as $it) { + $lvl = (int) ($it['level'] ?? 0); + $pItem = $parents[$lvl] ?? $root; + + $itype = (string) ($it['item_type'] ?? 'dir'); + $itTitle = (string) ($it['title'] ?? ''); + $path = $resolvePath($it); + + $item = (new CLpItem()) + ->setLp($lp) + ->setParent($pItem) + ->setItemType($itype) + ->setTitle($itTitle) + ->setPath($path) + ->setRef((string) ($it['identifier'] ?? '')) + ->setDisplayOrder(++$order) + ; + + if (isset($it['parameters'])) { + $item->setParameters((string) $it['parameters']); } - $em->flush(); - error_log($logp."Standard LP id=".$lp->getIid()." items=".count($lpObj->items)); + if (isset($it['prerequisite'])) { + $item->setPrerequisite((string) $it['prerequisite']); + } + if (isset($it['launch_data'])) { + $item->setLaunchData((string) $it['launch_data']); + } + + $lpItemRepo->create($item); + $parents[$lvl + 1] = $item; } + + $em->flush(); + + $this->dlog('LP: items created', [ + 'lp_iid' => (int) $lp->getIid(), + 'items' => $order, + 'title' => $title, + ]); } } /** - * Restore glossary. + * Restore Glossary resources for the destination course. + * + * @param mixed $sessionId */ - public function restore_glossary($sessionId = 0) + public function restore_glossary($sessionId = 0): void { if (!$this->course->has_resources(RESOURCE_GLOSSARY)) { $this->debug && error_log('COURSE_DEBUG: restore_glossary: no glossary resources in backup.'); + return; } - $em = Database::getManager(); + $em = Database::getManager(); + /** @var CGlossaryRepository $repo */ - $repo = $em->getRepository(CGlossary::class); + $repo = $em->getRepository(CGlossary::class); + /** @var CourseEntity $courseEntity */ - $courseEntity = api_get_course_entity($this->destination_course_id); + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null; - $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; - if ($backupRoot === '') { - $this->debug && error_log('COURSE_DEBUG: restore_glossary: backupRoot empty; URL rewriting may be partial.'); - } - $resources = $this->course->resources; foreach ($resources[RESOURCE_GLOSSARY] as $legacyId => $gls) { try { $title = (string) ($gls->name ?? $gls->title ?? ''); - $desc = (string) ($gls->description ?? ''); - $order = (int) ($gls->display_order ?? 0); + $desc = (string) ($gls->description ?? ''); + $order = (int) ($gls->display_order ?? 0); + + // Normalize title + if ('' === $title) { + $title = 'Glossary term'; + } - $desc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets($desc, $courseEntity, $backupRoot) ?? $desc; + // HTML rewrite (always) + $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[glossary.term]'); - $existing = null; + // Look up existing by title in this course + (optional) session if (method_exists($repo, 'getResourcesByCourse')) { $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity) - ->andWhere('resource.title = :title') - ->setParameter('title', $title) - ->setMaxResults(1); + ->andWhere('resource.title = :title')->setParameter('title', $title) + ->setMaxResults(1) + ; $existing = $qb->getQuery()->getOneOrNullResult(); } else { $existing = $repo->findOneBy(['title' => $title]); @@ -3558,51 +3959,61 @@ public function restore_glossary($sessionId = 0) if ($existing instanceof CGlossary) { switch ($this->file_option) { case FILE_SKIP: - $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int)$existing->getIid(); - $this->debug && error_log("COURSE_DEBUG: restore_glossary: term exists title='{$title}' (skip)."); + $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass(); + $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int) $existing->getIid(); + $this->debug && error_log("COURSE_DEBUG: restore_glossary: exists title='{$title}' (skip)."); + continue 2; case FILE_RENAME: - $base = $title === '' ? 'Glossary term' : $title; - $try = $base; - $i = 1; - $isTaken = static function($repo, $courseEntity, $sessionEntity, $titleTry) { + // Generate a unique title inside the course/session + $base = $title; + $try = $base; + $i = 1; + $isTaken = static function ($repo, $courseEntity, $sessionEntity, $titleTry) { if (method_exists($repo, 'getResourcesByCourse')) { $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity) ->andWhere('resource.title = :t')->setParameter('t', $titleTry) - ->setMaxResults(1); - return (bool)$qb->getQuery()->getOneOrNullResult(); + ->setMaxResults(1) + ; + + return (bool) $qb->getQuery()->getOneOrNullResult(); } - return (bool)$repo->findOneBy(['title' => $titleTry]); + + return (bool) $repo->findOneBy(['title' => $titleTry]); }; while ($isTaken($repo, $courseEntity, $sessionEntity, $try)) { $try = $base.' ('.($i++).')'; } $title = $try; $this->debug && error_log("COURSE_DEBUG: restore_glossary: renaming to '{$title}'."); + break; case FILE_OVERWRITE: $em->remove($existing); $em->flush(); - $this->debug && error_log("COURSE_DEBUG: restore_glossary: existing term deleted (overwrite)."); + $this->debug && error_log('COURSE_DEBUG: restore_glossary: existing term deleted (overwrite).'); + break; default: - $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int)$existing->getIid(); + $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass(); + $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int) $existing->getIid(); + continue 2; } } - $entity = new CGlossary(); - $entity + // Create + $entity = (new CGlossary()) ->setTitle($title) - ->setDescription($desc); + ->setDescription($desc) + ; if (method_exists($entity, 'setParent')) { $entity->setParent($courseEntity); } - if (method_exists($entity, 'addCourseLink')) { $entity->addCourseLink($courseEntity, $sessionEntity); } @@ -3619,112 +4030,119 @@ public function restore_glossary($sessionId = 0) $em->flush(); } - $newId = (int)$entity->getIid(); - if (!isset($this->course->resources[RESOURCE_GLOSSARY][$legacyId])) { - $this->course->resources[RESOURCE_GLOSSARY][$legacyId] = new \stdClass(); - } + $newId = (int) $entity->getIid(); + $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass(); $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = $newId; $this->debug && error_log("COURSE_DEBUG: restore_glossary: created term iid={$newId}, title='{$title}'"); - } catch (\Throwable $e) { + } catch (Throwable $e) { error_log('COURSE_DEBUG: restore_glossary: failed: '.$e->getMessage()); + continue; } } } /** - * @param int $sessionId + * Restore Wiki resources for the destination course. + * + * @param mixed $sessionId */ - public function restore_wiki($sessionId = 0) + public function restore_wiki($sessionId = 0): void { if (!$this->course->has_resources(RESOURCE_WIKI)) { $this->debug && error_log('COURSE_DEBUG: restore_wiki: no wiki resources in backup.'); + return; } - $em = Database::getManager(); + $em = Database::getManager(); + /** @var CWikiRepository $repo */ - $repo = $em->getRepository(CWiki::class); + $repo = $em->getRepository(CWiki::class); + /** @var CourseEntity $courseEntity */ - $courseEntity = api_get_course_entity($this->destination_course_id); - /** @var SessionEntity|null $sessionEntity */ - $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; + $courseEntity = api_get_course_entity($this->destination_course_id); - $cid = (int)$this->destination_course_id; - $sid = (int)($sessionEntity?->getId() ?? 0); + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null; - $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; - if ($backupRoot === '') { - $this->debug && error_log('COURSE_DEBUG: restore_wiki: backupRoot empty; URL rewriting may be partial.'); - } + $cid = (int) $this->destination_course_id; + $sid = (int) ($sessionEntity?->getId() ?? 0); $resources = $this->course->resources; foreach ($resources[RESOURCE_WIKI] as $legacyId => $w) { try { - $rawTitle = (string)($w->title ?? $w->name ?? ''); - $reflink = (string)($w->reflink ?? ''); - $content = (string)($w->content ?? ''); - $comment = (string)($w->comment ?? ''); - $progress = (string)($w->progress ?? ''); - $version = (int) ($w->version ?? 1); - $groupId = (int) ($w->group_id ?? 0); - $userId = (int) ($w->user_id ?? api_get_user_id()); - $dtimeStr = (string)($w->dtime ?? ''); - $dtime = null; - try { $dtime = $dtimeStr !== '' ? new \DateTime($dtimeStr) : new \DateTime('now', new \DateTimeZone('UTC')); } - catch (\Throwable) { $dtime = new \DateTime('now', new \DateTimeZone('UTC')); } - - $content = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $content, - $courseEntity, - $backupRoot - ) ?? $content; - - if ($rawTitle === '') { + $rawTitle = (string) ($w->title ?? $w->name ?? ''); + $reflink = (string) ($w->reflink ?? ''); + $content = (string) ($w->content ?? ''); + $comment = (string) ($w->comment ?? ''); + $progress = (string) ($w->progress ?? ''); + $version = (int) ($w->version ?? 1); + $groupId = (int) ($w->group_id ?? 0); + $userId = (int) ($w->user_id ?? api_get_user_id()); + + // HTML rewrite + $content = $this->rewriteHtmlForCourse($content, (int) $sessionId, '[wiki.page]'); + + if ('' === $rawTitle) { $rawTitle = 'Wiki page'; } - if ($content === '') { + if ('' === $content) { $content = '

 

'; } + // slug maker $makeSlug = static function (string $s): string { $s = strtolower(trim($s)); $s = preg_replace('/[^\p{L}\p{N}]+/u', '-', $s) ?: ''; $s = trim($s, '-'); - return $s === '' ? 'page' : $s; + + return '' === $s ? 'page' : $s; }; - $reflink = $reflink !== '' ? $makeSlug($reflink) : $makeSlug($rawTitle); + $reflink = '' !== $reflink ? $makeSlug($reflink) : $makeSlug($rawTitle); + // existence check $qbExists = $repo->createQueryBuilder('w') ->select('w.iid') ->andWhere('w.cId = :cid')->setParameter('cid', $cid) ->andWhere('w.reflink = :r')->setParameter('r', $reflink) - ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId); + ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId) + ; if ($sid > 0) { $qbExists->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); } else { $qbExists->andWhere('COALESCE(w.sessionId,0) = 0'); } - $exists = (bool)$qbExists->getQuery()->getOneOrNullResult(); + + $exists = (bool) $qbExists->getQuery()->getOneOrNullResult(); if ($exists) { switch ($this->file_option) { case FILE_SKIP: + // map to latest page id $qbLast = $repo->createQueryBuilder('w') ->andWhere('w.cId = :cid')->setParameter('cid', $cid) ->andWhere('w.reflink = :r')->setParameter('r', $reflink) ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId) - ->orderBy('w.version', 'DESC')->setMaxResults(1); - if ($sid > 0) { $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); } - else { $qbLast->andWhere('COALESCE(w.sessionId,0) = 0'); } + ->orderBy('w.version', 'DESC')->setMaxResults(1) + ; + if ($sid > 0) { + $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); + } else { + $qbLast->andWhere('COALESCE(w.sessionId,0) = 0'); + } /** @var CWiki|null $last */ $last = $qbLast->getQuery()->getOneOrNullResult(); - $dest = $last ? (int)($last->getPageId() ?: $last->getIid()) : 0; + $dest = $last ? (int) ($last->getPageId() ?: $last->getIid()) : 0; + + $this->course->resources[RESOURCE_WIKI][$legacyId] ??= new stdClass(); $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = $dest; - $this->debug && error_log("COURSE_DEBUG: restore_wiki: reflink '{$reflink}' exists → skip (page_id={$dest})."); + + $this->debug && error_log("COURSE_DEBUG: restore_wiki: exists → skip (page_id={$dest})."); + continue 2; case FILE_RENAME: @@ -3737,51 +4155,73 @@ public function restore_wiki($sessionId = 0) ->select('w.iid') ->andWhere('w.cId = :cid')->setParameter('cid', $cid) ->andWhere('w.reflink = :r')->setParameter('r', $slug) - ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId); - if ($sid > 0) $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); - else $qb->andWhere('COALESCE(w.sessionId,0) = 0'); + ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId) + ; + if ($sid > 0) { + $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); + } else { + $qb->andWhere('COALESCE(w.sessionId,0) = 0'); + } $qb->setMaxResults(1); - return (bool)$qb->getQuery()->getOneOrNullResult(); + + return (bool) $qb->getQuery()->getOneOrNullResult(); }; - while ($isTaken($trySlug)) { $trySlug = $baseSlug.'-'.(++$i); } - $reflink = $trySlug; + while ($isTaken($trySlug)) { + $trySlug = $baseSlug.'-'.(++$i); + } + $reflink = $trySlug; $rawTitle = $baseTitle.' ('.$i.')'; - $this->debug && error_log("COURSE_DEBUG: restore_wiki: renamed reflink to '{$reflink}' / title='{$rawTitle}'."); + $this->debug && error_log("COURSE_DEBUG: restore_wiki: renamed to '{$reflink}' / '{$rawTitle}'."); + break; case FILE_OVERWRITE: $qbAll = $repo->createQueryBuilder('w') ->andWhere('w.cId = :cid')->setParameter('cid', $cid) ->andWhere('w.reflink = :r')->setParameter('r', $reflink) - ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId); - if ($sid > 0) $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); - else $qbAll->andWhere('COALESCE(w.sessionId,0) = 0'); - + ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId) + ; + if ($sid > 0) { + $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); + } else { + $qbAll->andWhere('COALESCE(w.sessionId,0) = 0'); + } foreach ($qbAll->getQuery()->getResult() as $old) { $em->remove($old); } $em->flush(); - $this->debug && error_log("COURSE_DEBUG: restore_wiki: removed previous pages for reflink '{$reflink}' (overwrite)."); + $this->debug && error_log('COURSE_DEBUG: restore_wiki: removed old pages (overwrite).'); + break; default: - $this->debug && error_log("COURSE_DEBUG: restore_wiki: unknown file_option → skip."); + $this->debug && error_log('COURSE_DEBUG: restore_wiki: unknown file_option → skip.'); + continue 2; } } + // Create new page (one version) $wiki = new CWiki(); $wiki->setCId($cid); $wiki->setSessionId($sid); $wiki->setGroupId($groupId); $wiki->setReflink($reflink); $wiki->setTitle($rawTitle); - $wiki->setContent($content); + $wiki->setContent($content); // already rewritten $wiki->setComment($comment); $wiki->setProgress($progress); $wiki->setVersion($version > 0 ? $version : 1); $wiki->setUserId($userId); - $wiki->setDtime($dtime); + + // timestamps + try { + $dtimeStr = (string) ($w->dtime ?? ''); + $wiki->setDtime('' !== $dtimeStr ? new DateTime($dtimeStr) : new DateTime('now', new DateTimeZone('UTC'))); + } catch (Throwable) { + $wiki->setDtime(new DateTime('now', new DateTimeZone('UTC'))); + } + $wiki->setIsEditing(0); $wiki->setTimeEdit(null); $wiki->setHits((int) ($w->hits ?? 0)); @@ -3811,15 +4251,16 @@ public function restore_wiki($sessionId = 0) $em->persist($wiki); $em->flush(); + // Page id if (empty($w->page_id)) { $wiki->setPageId((int) $wiki->getIid()); - $em->flush(); } else { $pid = (int) $w->page_id; $wiki->setPageId($pid > 0 ? $pid : (int) $wiki->getIid()); - $em->flush(); } + $em->flush(); + // Conf row $conf = new CWikiConf(); $conf->setCId($cid); $conf->setPageId((int) $wiki->getPageId()); @@ -3832,77 +4273,81 @@ public function restore_wiki($sessionId = 0) $conf->setFprogress3((string) ($w->fprogress3 ?? '')); $conf->setMaxText(isset($w->max_text) ? (int) $w->max_text : 0); $conf->setMaxVersion(isset($w->max_version) ? (int) $w->max_version : 0); + try { - $conf->setStartdateAssig(!empty($w->startdate_assig) ? new \DateTime((string) $w->startdate_assig) : null); - } catch (\Throwable) { $conf->setStartdateAssig(null); } + $conf->setStartdateAssig(!empty($w->startdate_assig) ? new DateTime((string) $w->startdate_assig) : null); + } catch (Throwable) { + $conf->setStartdateAssig(null); + } + try { - $conf->setEnddateAssig(!empty($w->enddate_assig) ? new \DateTime((string) $w->enddate_assig) : null); - } catch (\Throwable) { $conf->setEnddateAssig(null); } + $conf->setEnddateAssig(!empty($w->enddate_assig) ? new DateTime((string) $w->enddate_assig) : null); + } catch (Throwable) { + $conf->setEnddateAssig(null); + } $conf->setDelayedsubmit(isset($w->delayedsubmit) ? (int) $w->delayedsubmit : 0); $em->persist($conf); $em->flush(); + $this->course->resources[RESOURCE_WIKI][$legacyId] ??= new stdClass(); $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = (int) $wiki->getPageId(); - $this->debug && error_log("COURSE_DEBUG: restore_wiki: created page iid=".(int) $wiki->getIid()." page_id=".(int) $wiki->getPageId()." reflink='{$reflink}'"); - } catch (\Throwable $e) { + $this->debug && error_log('COURSE_DEBUG: restore_wiki: created page iid='.(int) $wiki->getIid().' page_id='.(int) $wiki->getPageId()." reflink='{$reflink}'"); + } catch (Throwable $e) { error_log('COURSE_DEBUG: restore_wiki: failed: '.$e->getMessage()); + continue; } } } /** - * Restore Thematics. + * Restore "Thematic" resources for the destination course. * - * @param int $sessionId + * @param mixed $sessionId */ - public function restore_thematic($sessionId = 0) + public function restore_thematic($sessionId = 0): void { if (!$this->course->has_resources(RESOURCE_THEMATIC)) { $this->debug && error_log('COURSE_DEBUG: restore_thematic: no thematic resources.'); + return; } - $em = Database::getManager(); - /** @var CourseEntity $courseEntity */ - $courseEntity = api_get_course_entity($this->destination_course_id); - /** @var SessionEntity|null $sessionEntity */ - $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; + $em = Database::getManager(); - $cid = (int)$this->destination_course_id; - $sid = (int)($sessionEntity?->getId() ?? 0); + /** @var CourseEntity $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); - $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null; $resources = $this->course->resources; foreach ($resources[RESOURCE_THEMATIC] as $legacyId => $t) { try { - $p = (array)($t->params ?? []); - $title = trim((string)($p['title'] ?? $p['name'] ?? '')); - $content = (string)($p['content'] ?? ''); - $active = (bool) ($p['active'] ?? true); + $p = (array) ($t->params ?? []); - if ($content !== '') { - $content = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $content, - $courseEntity, - $backupRoot - ) ?? $content; - } + $title = trim((string) ($p['title'] ?? $p['name'] ?? '')); + $content = (string) ($p['content'] ?? ''); + $active = (bool) ($p['active'] ?? true); - if ($title === '') { + if ('' === $title) { $title = 'Thematic'; } - $thematic = new CThematic(); - $thematic + // Rewrite embedded HTML so referenced files/images are valid in the new course + $content = $this->rewriteHtmlForCourse($content, (int) $sessionId, '[thematic.main]'); + + // Create Thematic root + $thematic = (new CThematic()) ->setTitle($title) ->setContent($content) - ->setActive($active); + ->setActive($active) + ; + // Set ownership and course linkage if available if (method_exists($thematic, 'setParent')) { $thematic->setParent($courseEntity); } @@ -3916,48 +4361,49 @@ public function restore_thematic($sessionId = 0) $em->persist($thematic); $em->flush(); - $this->course->resources[RESOURCE_THEMATIC][$legacyId]->destination_id = (int)$thematic->getIid(); + // Map new IID back to resources + $this->course->resources[RESOURCE_THEMATIC][$legacyId] ??= new stdClass(); + $this->course->resources[RESOURCE_THEMATIC][$legacyId]->destination_id = (int) $thematic->getIid(); - $advList = (array)($t->thematic_advance_list ?? []); + // Restore "advances" (timeline slots) + $advList = (array) ($t->thematic_advance_list ?? []); foreach ($advList as $adv) { - if (!is_array($adv)) { $adv = (array)$adv; } - - $advContent = (string)($adv['content'] ?? ''); - if ($advContent !== '') { - $advContent = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $advContent, - $courseEntity, - $backupRoot - ) ?? $advContent; + if (!\is_array($adv)) { + $adv = (array) $adv; } - $startStr = (string)($adv['start_date'] ?? $adv['startDate'] ?? ''); + $advContent = (string) ($adv['content'] ?? ''); + // Rewrite HTML inside advance content + $advContent = $this->rewriteHtmlForCourse($advContent, (int) $sessionId, '[thematic.advance]'); + + $rawStart = (string) ($adv['start_date'] ?? $adv['startDate'] ?? ''); + try { - $startDate = $startStr !== '' ? new \DateTime($startStr) : new \DateTime('now', new \DateTimeZone('UTC')); - } catch (\Throwable) { - $startDate = new \DateTime('now', new \DateTimeZone('UTC')); + $startDate = '' !== $rawStart ? new DateTime($rawStart) : new DateTime('now', new DateTimeZone('UTC')); + } catch (Throwable) { + $startDate = new DateTime('now', new DateTimeZone('UTC')); } - $duration = (int)($adv['duration'] ?? 1); - $doneAdvance = (bool)($adv['done_advance'] ?? $adv['doneAdvance'] ?? false); + $duration = (int) ($adv['duration'] ?? 1); + $doneAdvance = (bool) ($adv['done_advance'] ?? $adv['doneAdvance'] ?? false); - $advance = new CThematicAdvance(); - $advance + $advance = (new CThematicAdvance()) ->setThematic($thematic) ->setContent($advContent) ->setStartDate($startDate) ->setDuration($duration) - ->setDoneAdvance($doneAdvance); + ->setDoneAdvance($doneAdvance) + ; - $attId = (int)($adv['attendance_id'] ?? 0); + // Optional links to attendance/room if present + $attId = (int) ($adv['attendance_id'] ?? 0); if ($attId > 0) { $att = $em->getRepository(CAttendance::class)->find($attId); if ($att) { $advance->setAttendance($att); } } - - $roomId = (int)($adv['room_id'] ?? 0); + $roomId = (int) ($adv['room_id'] ?? 0); if ($roomId > 0) { $room = $em->getRepository(Room::class)->find($roomId); if ($room) { @@ -3968,96 +4414,106 @@ public function restore_thematic($sessionId = 0) $em->persist($advance); } - $planList = (array)($t->thematic_plan_list ?? []); + // Restore "plans" (structured descriptions) + $planList = (array) ($t->thematic_plan_list ?? []); foreach ($planList as $pl) { - if (!is_array($pl)) { $pl = (array)$pl; } - - $plTitle = trim((string)($pl['title'] ?? '')); - if ($plTitle === '') { $plTitle = 'Plan'; } - - $plDesc = (string)($pl['description'] ?? ''); - if ($plDesc !== '') { - $plDesc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $plDesc, - $courseEntity, - $backupRoot - ) ?? $plDesc; + if (!\is_array($pl)) { + $pl = (array) $pl; + } + + $plTitle = trim((string) ($pl['title'] ?? '')); + if ('' === $plTitle) { + $plTitle = 'Plan'; } - $descType = (int)($pl['description_type'] ?? $pl['descriptionType'] ?? 0); + $plDesc = (string) ($pl['description'] ?? ''); + // Rewrite HTML inside plan description + $plDesc = $this->rewriteHtmlForCourse($plDesc, (int) $sessionId, '[thematic.plan]'); - $plan = new CThematicPlan(); - $plan + $descType = (int) ($pl['description_type'] ?? $pl['descriptionType'] ?? 0); + + $plan = (new CThematicPlan()) ->setThematic($thematic) ->setTitle($plTitle) ->setDescription($plDesc) - ->setDescriptionType($descType); + ->setDescriptionType($descType) + ; $em->persist($plan); } + // Flush once per thematic (advances + plans) $em->flush(); - $this->debug && error_log("COURSE_DEBUG: restore_thematic: created thematic iid=".(int)$thematic->getIid()." (advances=".count($advList).", plans=".count($planList).")"); - } catch (\Throwable $e) { + $this->debug && error_log( + 'COURSE_DEBUG: restore_thematic: created thematic iid='.(int) $thematic->getIid(). + ' (advances='.\count($advList).', plans='.\count($planList).')' + ); + } catch (Throwable $e) { error_log('COURSE_DEBUG: restore_thematic: failed: '.$e->getMessage()); + continue; } } } /** - * Restore Attendance. + * Restore "Attendance" resources (register + calendar slots). * - * @param int $sessionId + * @param mixed $sessionId */ - public function restore_attendance($sessionId = 0) + public function restore_attendance($sessionId = 0): void { if (!$this->course->has_resources(RESOURCE_ATTENDANCE)) { $this->debug && error_log('COURSE_DEBUG: restore_attendance: no attendance resources.'); + return; } - $em = Database::getManager(); + $em = Database::getManager(); + /** @var CourseEntity $courseEntity */ - $courseEntity = api_get_course_entity($this->destination_course_id); - /** @var SessionEntity|null $sessionEntity */ - $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; + $courseEntity = api_get_course_entity($this->destination_course_id); - $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null; $resources = $this->course->resources; foreach ($resources[RESOURCE_ATTENDANCE] as $legacyId => $att) { try { - $p = (array)($att->params ?? []); + $p = (array) ($att->params ?? []); - $title = trim((string)($p['title'] ?? 'Attendance')); - $desc = (string)($p['description'] ?? ''); - $active = (int)($p['active'] ?? 1); + $title = trim((string) ($p['title'] ?? 'Attendance')); + $desc = (string) ($p['description'] ?? ''); + $active = (int) ($p['active'] ?? 1); - if ($desc !== '') { - $desc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $desc, - $courseEntity, - $backupRoot - ) ?? $desc; + // Normalize title + if ('' === $title) { + $title = 'Attendance'; } - $qualTitle = isset($p['attendance_qualify_title']) ? (string)$p['attendance_qualify_title'] : null; - $qualMax = (int)($p['attendance_qualify_max'] ?? 0); - $weight = (float)($p['attendance_weight'] ?? 0.0); - $locked = (int)($p['locked'] ?? 0); + // Rewrite HTML in description (links to course files, etc.) + $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[attendance.main]'); - $a = new CAttendance(); - $a->setTitle($title) + // Optional grading attributes + $qualTitle = isset($p['attendance_qualify_title']) ? (string) $p['attendance_qualify_title'] : null; + $qualMax = (int) ($p['attendance_qualify_max'] ?? 0); + $weight = (float) ($p['attendance_weight'] ?? 0.0); + $locked = (int) ($p['locked'] ?? 0); + + // Create attendance entity + $a = (new CAttendance()) + ->setTitle($title) ->setDescription($desc) ->setActive($active) ->setAttendanceQualifyTitle($qualTitle ?? '') ->setAttendanceQualifyMax($qualMax) ->setAttendanceWeight($weight) - ->setLocked($locked); + ->setLocked($locked) + ; + // Link to course & creator if supported if (method_exists($a, 'setParent')) { $a->setParent($courseEntity); } @@ -4071,60 +4527,72 @@ public function restore_attendance($sessionId = 0) $em->persist($a); $em->flush(); - $this->course->resources[RESOURCE_ATTENDANCE][$legacyId]->destination_id = (int)$a->getIid(); + // Map new IID back + $this->course->resources[RESOURCE_ATTENDANCE][$legacyId] ??= new stdClass(); + $this->course->resources[RESOURCE_ATTENDANCE][$legacyId]->destination_id = (int) $a->getIid(); - $calList = (array)($att->attendance_calendar ?? []); + // Restore calendar entries (slots) + $calList = (array) ($att->attendance_calendar ?? []); foreach ($calList as $c) { - if (!is_array($c)) { $c = (array)$c; } + if (!\is_array($c)) { + $c = (array) $c; + } + + // Date/time normalization with fallbacks + $rawDt = (string) ($c['date_time'] ?? $c['dateTime'] ?? $c['start_date'] ?? ''); - $rawDt = (string)($c['date_time'] ?? $c['dateTime'] ?? $c['start_date'] ?? ''); try { - $dt = $rawDt !== '' ? new \DateTime($rawDt) : new \DateTime('now', new \DateTimeZone('UTC')); - } catch (\Throwable) { - $dt = new \DateTime('now', new \DateTimeZone('UTC')); + $dt = '' !== $rawDt ? new DateTime($rawDt) : new DateTime('now', new DateTimeZone('UTC')); + } catch (Throwable) { + $dt = new DateTime('now', new DateTimeZone('UTC')); } - $done = (bool)($c['done_attendance'] ?? $c['doneAttendance'] ?? false); - $blocked = (bool)($c['blocked'] ?? false); - $duration = isset($c['duration']) ? (int)$c['duration'] : null; + $done = (bool) ($c['done_attendance'] ?? $c['doneAttendance'] ?? false); + $blocked = (bool) ($c['blocked'] ?? false); + $duration = isset($c['duration']) ? (int) $c['duration'] : null; - $cal = new CAttendanceCalendar(); - $cal->setAttendance($a) + $cal = (new CAttendanceCalendar()) + ->setAttendance($a) ->setDateTime($dt) ->setDoneAttendance($done) ->setBlocked($blocked) - ->setDuration($duration); + ->setDuration($duration) + ; $em->persist($cal); $em->flush(); - $groupId = (int)($c['group_id'] ?? 0); + // Optionally attach a group to the calendar slot + $groupId = (int) ($c['group_id'] ?? 0); if ($groupId > 0) { try { $repo = $em->getRepository(CAttendanceCalendarRelGroup::class); if (method_exists($repo, 'addGroupToCalendar')) { - $repo->addGroupToCalendar((int)$cal->getIid(), $groupId); + $repo->addGroupToCalendar((int) $cal->getIid(), $groupId); } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->debug && error_log('COURSE_DEBUG: restore_attendance: calendar group link skipped: '.$e->getMessage()); } } } + // Flush at the end for this attendance $em->flush(); - $this->debug && error_log('COURSE_DEBUG: restore_attendance: created attendance iid='.(int)$a->getIid().' (cal='.count($calList).')'); - - } catch (\Throwable $e) { + $this->debug && error_log('COURSE_DEBUG: restore_attendance: created attendance iid='.(int) $a->getIid().' (cal='.\count($calList).')'); + } catch (Throwable $e) { error_log('COURSE_DEBUG: restore_attendance: failed: '.$e->getMessage()); + continue; } } } /** - * Restore Works. - * - * @param int $sessionId + * Restore Student Publications (works) from backup selection. + * - Honors file policy: FILE_SKIP (1), FILE_RENAME (2), FILE_OVERWRITE (3) + * - Creates a fresh ResourceNode for new items to avoid unique key collisions + * - Keeps existing behavior: HTML rewriting, optional calendar event, destination_id mapping + * - NO entity manager reopen helper (we avoid violations proactively). */ public function restore_works(int $sessionId = 0): void { @@ -4132,65 +4600,132 @@ public function restore_works(int $sessionId = 0): void return; } - $em = Database::getManager(); + $em = Database::getManager(); + /** @var CourseEntity $courseEntity */ - $courseEntity = api_get_course_entity($this->destination_course_id); + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null; - $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; - /** @var CStudentPublicationRepository $pubRepo */ $pubRepo = Container::getStudentPublicationRepository(); + // Same-name policy already mapped at controller/restorer level + $filePolicy = $this->file_option ?? (\defined('FILE_RENAME') ? FILE_RENAME : 2); + + $this->dlog('restore_works: begin', [ + 'count' => \count($this->course->resources[RESOURCE_WORK] ?? []), + 'policy' => $filePolicy, + ]); + + // Helper: generate a unique title within (course, session) scope + $makeUniqueTitle = function (string $base) use ($pubRepo, $courseEntity, $sessionEntity): string { + $t = '' !== $base ? $base : 'Work'; + $n = 0; + $title = $t; + while (true) { + $qb = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $title, null, 'folder'); + $exists = $qb + ->andWhere('resource.publicationParent IS NULL') + ->andWhere('resource.active IN (0,1)') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + if (!$exists) { + return $title; + } + $n++; + $title = $t.' ('.$n.')'; + } + }; + + // Helper: create a fresh ResourceNode for the publication + $createResourceNode = function (string $title) use ($em, $courseEntity, $sessionEntity) { + $nodeClass = ResourceNode::class; + $node = new $nodeClass(); + if (method_exists($node, 'setTitle')) { + $node->setTitle($title); + } + if (method_exists($node, 'setCourse')) { + $node->setCourse($courseEntity); + } + if (method_exists($node, 'addCourseLink')) { + $node->addCourseLink($courseEntity, $sessionEntity); + } + if (method_exists($node, 'setResourceType')) { + $node->setResourceType('student_publication'); + } + $em->persist($node); + + // flush is deferred to the publication flush + return $node; + }; + foreach ($this->course->resources[RESOURCE_WORK] as $legacyId => $obj) { try { - $p = (array)($obj->params ?? []); - - $title = trim((string)($p['title'] ?? 'Work')); - if ($title === '') { $title = 'Work'; } - - $description = (string)($p['description'] ?? ''); - if ($description !== '') { - $description = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( - $description, - $courseEntity, - $backupRoot - ) ?? $description; - } - - $enableQualification = (bool)($p['enable_qualification'] ?? false); - $addToCalendar = (int)($p['add_to_calendar'] ?? 0) === 1; - $expiresOn = !empty($p['expires_on']) ? new \DateTime($p['expires_on']) : null; - $endsOn = !empty($p['ends_on']) ? new \DateTime($p['ends_on']) : null; - - $weight = isset($p['weight']) ? (float)$p['weight'] : 0.0; - $qualification = isset($p['qualification']) ? (float)$p['qualification'] : 0.0; - $allowText = (int)($p['allow_text_assignment'] ?? 0); - $defaultVisibility = (bool)($p['default_visibility'] ?? 0); - $studentMayDelete = (bool)($p['student_delete_own_publication'] ?? 0); - $extensions = isset($p['extensions']) ? (string)$p['extensions'] : null; - $groupCategoryWorkId = (int)($p['group_category_work_id'] ?? 0); - $postGroupId = (int)($p['post_group_id'] ?? 0); - - $existingQb = $pubRepo->findAllByCourse( - $courseEntity, - $sessionEntity, - $title, - null, - 'folder' - ); + $p = (array) ($obj->params ?? []); + + $title = trim((string) ($p['title'] ?? 'Work')); + if ('' === $title) { + $title = 'Work'; + } + $originalTitle = $title; + + $description = (string) ($p['description'] ?? ''); + // HTML rewrite (assignment description) + $description = $this->rewriteHtmlForCourse($description, (int) $sessionId, '[work.description]'); + + $enableQualification = (bool) ($p['enable_qualification'] ?? false); + $addToCalendar = 1 === (int) ($p['add_to_calendar'] ?? 0); + + $expiresOn = !empty($p['expires_on']) ? new DateTime($p['expires_on']) : null; + $endsOn = !empty($p['ends_on']) ? new DateTime($p['ends_on']) : null; + + $weight = isset($p['weight']) ? (float) $p['weight'] : 0.0; + $qualification = isset($p['qualification']) ? (float) $p['qualification'] : 0.0; + $allowText = (int) ($p['allow_text_assignment'] ?? 0); + $defaultVisibility = (bool) ($p['default_visibility'] ?? 0); + $studentMayDelete = (bool) ($p['student_delete_own_publication'] ?? 0); + $extensions = isset($p['extensions']) ? (string) $p['extensions'] : null; + $groupCategoryWorkId = (int) ($p['group_category_work_id'] ?? 0); + $postGroupId = (int) ($p['post_group_id'] ?? 0); + + // Check for existing root folder with same title + $existingQb = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $title, null, 'folder'); $existing = $existingQb ->andWhere('resource.publicationParent IS NULL') ->andWhere('resource.active IN (0,1)') ->setMaxResults(1) ->getQuery() - ->getOneOrNullResult(); + ->getOneOrNullResult() + ; + + // Apply same-name policy proactively (avoid unique violations) + if ($existing) { + if ($filePolicy === (\defined('FILE_SKIP') ? FILE_SKIP : 1)) { + $this->dlog('WORK: skip existing title', ['title' => $title, 'src_id' => $legacyId]); + $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass(); + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid(); + + continue; + } + if ($filePolicy === (\defined('FILE_RENAME') ? FILE_RENAME : 2)) { + $title = $makeUniqueTitle($title); + $existing = null; // force a new one + } + // FILE_OVERWRITE: keep $existing and update below + } else { + // No existing — still ensure uniqueness to avoid slug/node collisions + $title = $makeUniqueTitle($title); + } if (!$existing) { - $pub = new CStudentPublication(); - $pub->setTitle($title) - ->setDescription($description) + // Create NEW publication (folder) + NEW resource node + $pub = (new CStudentPublication()) + ->setTitle($title) + ->setDescription($description) // already rewritten ->setFiletype('folder') ->setContainsFile(0) ->setWeight($weight) @@ -4200,7 +4735,8 @@ public function restore_works(int $sessionId = 0): void ->setStudentDeleteOwnPublication($studentMayDelete) ->setExtensions($extensions) ->setGroupCategoryWorkId($groupCategoryWorkId) - ->setPostGroupId($postGroupId); + ->setPostGroupId($postGroupId) + ; if (method_exists($pub, 'setParent')) { $pub->setParent($courseEntity); @@ -4211,26 +4747,50 @@ public function restore_works(int $sessionId = 0): void if (method_exists($pub, 'addCourseLink')) { $pub->addCourseLink($courseEntity, $sessionEntity); } + if (method_exists($pub, 'setResourceNode')) { + $pub->setResourceNode($createResourceNode($title)); + } $em->persist($pub); - $em->flush(); - // Assignment - $assignment = new CStudentPublicationAssignment(); - $assignment->setPublication($pub) - ->setEnableQualification($enableQualification || $qualification > 0); + try { + $em->flush(); + } catch (UniqueConstraintViolationException $e) { + // As a last resort, rename once and retry quickly (no EM reopen) + $this->dlog('WORK: unique violation on create, retry once with renamed title', [ + 'src_id' => $legacyId, + 'err' => $e->getMessage(), + ]); + $newTitle = $makeUniqueTitle($title); + if (method_exists($pub, 'setTitle')) { + $pub->setTitle($newTitle); + } + if (method_exists($pub, 'setResourceNode')) { + $pub->setResourceNode($createResourceNode($newTitle)); + } + $em->persist($pub); + $em->flush(); + } - if ($expiresOn) { $assignment->setExpiresOn($expiresOn); } - if ($endsOn) { $assignment->setEndsOn($endsOn); } + // Create Assignment row + $assignment = (new CStudentPublicationAssignment()) + ->setPublication($pub) + ->setEnableQualification($enableQualification || $qualification > 0) + ; + if ($expiresOn) { + $assignment->setExpiresOn($expiresOn); + } + if ($endsOn) { + $assignment->setEndsOn($endsOn); + } $em->persist($assignment); $em->flush(); - // Calendar (URL “Chamilo 2”: Router/UUID) + // Optional calendar entry if ($addToCalendar) { - $eventTitle = sprintf(get_lang('Handing over of task %s'), $pub->getTitle()); + $eventTitle = \sprintf(get_lang('Handing over of task %s'), $pub->getTitle()); - // URL por UUID o Router $publicationUrl = null; $uuid = $pub->getResourceNode()?->getUuid(); if ($uuid) { @@ -4241,24 +4801,25 @@ public function restore_works(int $sessionId = 0): void ['uuid' => (string) $uuid], UrlGeneratorInterface::ABSOLUTE_PATH ); - } catch (\Throwable) { - $publicationUrl = '/r/student_publication/'. $uuid; + } catch (Throwable) { + $publicationUrl = '/r/student_publication/'.$uuid; } } else { - $publicationUrl = '/r/student_publication/'. $uuid; + $publicationUrl = '/r/student_publication/'.$uuid; } } - $content = sprintf( + $contentBlock = \sprintf( '
%s
%s', $publicationUrl - ? sprintf('%s', $publicationUrl, $pub->getTitle()) + ? \sprintf('%s', $publicationUrl, htmlspecialchars($pub->getTitle(), ENT_QUOTES)) : htmlspecialchars($pub->getTitle(), ENT_QUOTES), $pub->getDescription() ); + $contentBlock = $this->rewriteHtmlForCourse($contentBlock, (int) $sessionId, '[work.calendar]'); - $start = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC')); - $end = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC')); + $start = $expiresOn ? clone $expiresOn : new DateTime('now', new DateTimeZone('UTC')); + $end = $expiresOn ? clone $expiresOn : new DateTime('now', new DateTimeZone('UTC')); $color = CCalendarEvent::COLOR_STUDENT_PUBLICATION; if ($colors = api_get_setting('agenda.agenda_colors')) { @@ -4269,25 +4830,35 @@ public function restore_works(int $sessionId = 0): void $event = (new CCalendarEvent()) ->setTitle($eventTitle) - ->setContent($content) + ->setContent($contentBlock) ->setParent($courseEntity) ->setCreator($pub->getCreator()) ->addLink(clone $pub->getFirstResourceLink()) ->setStartDate($start) ->setEndDate($end) - ->setColor($color); + ->setColor($color) + ; $em->persist($event); $em->flush(); - $assignment->setEventCalendarId((int)$event->getIid()); + $assignment->setEventCalendarId((int) $event->getIid()); $em->flush(); } - $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int)$pub->getIid(); + // Map destination for LP path resolution + $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass(); + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $pub->getIid(); + + $this->dlog('restore_works: created', [ + 'src_id' => (int) $legacyId, + 'dst_iid' => (int) $pub->getIid(), + 'title' => $pub->getTitle(), + ]); } else { + // FILE_OVERWRITE: update existing $existing - ->setDescription($description) + ->setDescription($this->rewriteHtmlForCourse((string) $description, (int) $sessionId, '[work.description.overwrite]')) ->setWeight($weight) ->setQualification($qualification) ->setAllowTextAssignment($allowText) @@ -4295,18 +4866,26 @@ public function restore_works(int $sessionId = 0): void ->setStudentDeleteOwnPublication($studentMayDelete) ->setExtensions($extensions) ->setGroupCategoryWorkId($groupCategoryWorkId) - ->setPostGroupId($postGroupId); + ->setPostGroupId($postGroupId) + ; + + // Ensure it has a ResourceNode + if (method_exists($existing, 'getResourceNode') && method_exists($existing, 'setResourceNode')) { + if (!$existing->getResourceNode()) { + $existing->setResourceNode($createResourceNode($existing->getTitle() ?: $originalTitle)); + } + } $em->persist($existing); $em->flush(); + // Assignment row $assignment = $existing->getAssignment(); if (!$assignment) { $assignment = new CStudentPublicationAssignment(); $assignment->setPublication($existing); $em->persist($assignment); } - $assignment->setEnableQualification($enableQualification || $qualification > 0); $assignment->setExpiresOn($expiresOn); $assignment->setEndsOn($endsOn); @@ -4315,118 +4894,152 @@ public function restore_works(int $sessionId = 0): void } $em->flush(); - $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int)$existing->getIid(); + $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass(); + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid(); + + $this->dlog('restore_works: overwritten existing', [ + 'src_id' => (int) $legacyId, + 'dst_iid' => (int) $existing->getIid(), + 'title' => $existing->getTitle(), + ]); } - } catch (\Throwable $e) { - error_log('COURSE_DEBUG: restore_works: '.$e->getMessage()); + } catch (Throwable $e) { + $this->dlog('restore_works: failed', [ + 'src_id' => (int) $legacyId, + 'err' => $e->getMessage(), + ]); + + // Do NOT try to reopen EM here (as requested) — just continue gracefully continue; } } - } + $this->dlog('restore_works: end'); + } + /** + * Restore the Gradebook structure (categories, evaluations, links). + * Overwrites destination gradebook for the course/session. + */ public function restore_gradebook(int $sessionId = 0): void { + // Only meaningful with OVERWRITE semantics (skip/rename make little sense here) if (\in_array($this->file_option, [FILE_SKIP, FILE_RENAME], true)) { return; } if (!$this->course->has_resources(RESOURCE_GRADEBOOK)) { $this->dlog('restore_gradebook: no gradebook resources'); + return; } /** @var EntityManagerInterface $em */ - $em = \Database::getManager(); + $em = Database::getManager(); /** @var Course $courseEntity */ - $courseEntity = api_get_course_entity($this->destination_course_id); + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null; + /** @var User $currentUser */ - $currentUser = api_get_user_entity(); + $currentUser = api_get_user_entity(); - $catRepo = $em->getRepository(GradebookCategory::class); + $catRepo = $em->getRepository(GradebookCategory::class); - // 1) Clean destination (overwrite semantics) + // Clean destination categories when overwriting try { $existingCats = $catRepo->findBy([ - 'course' => $courseEntity, + 'course' => $courseEntity, 'session' => $sessionEntity, ]); foreach ($existingCats as $cat) { - $em->remove($cat); // cascades remove evaluations/links + $em->remove($cat); } $em->flush(); - $this->dlog('restore_gradebook: destination cleaned', ['removed' => count($existingCats)]); - } catch (\Throwable $e) { + $this->dlog('restore_gradebook: destination cleaned', ['removed' => \count($existingCats)]); + } catch (Throwable $e) { $this->dlog('restore_gradebook: clean failed (continuing)', ['error' => $e->getMessage()]); } $oldIdToNewCat = []; - // 2) First pass: create all categories (no parent yet) + // First pass: create categories foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) { $categories = (array) ($gbItem->categories ?? []); foreach ($categories as $rawCat) { - $c = is_array($rawCat) ? $rawCat : (array) $rawCat; - - $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0); - $title = (string)($c['title'] ?? 'Category'); - $desc = (string)($c['description'] ?? ''); - $weight = (float) ($c['weight'] ?? 0.0); - $visible = (bool) ($c['visible'] ?? true); - $locked = (int) ($c['locked'] ?? 0); - - $new = new GradebookCategory(); - $new->setCourse($courseEntity); - $new->setSession($sessionEntity); - $new->setUser($currentUser); - $new->setTitle($title); - $new->setDescription($desc); - $new->setWeight($weight); - $new->setVisible($visible); - $new->setLocked($locked); - - // Optional fields if present in backup + $c = \is_array($rawCat) ? $rawCat : (array) $rawCat; + + $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0); + $title = (string) ($c['title'] ?? 'Category'); + $desc = (string) ($c['description'] ?? ''); + $weight = (float) ($c['weight'] ?? 0.0); + $visible = (bool) ($c['visible'] ?? true); + $locked = (int) ($c['locked'] ?? 0); + + // Rewrite HTML in category description + $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[gradebook.category]'); + + $new = (new GradebookCategory()) + ->setCourse($courseEntity) + ->setSession($sessionEntity) + ->setUser($currentUser) + ->setTitle($title) + ->setDescription($desc) + ->setWeight($weight) + ->setVisible($visible) + ->setLocked($locked) + ; + + // Optional flags (mirror legacy fields) if (isset($c['generate_certificates'])) { - $new->setGenerateCertificates((bool)$c['generate_certificates']); - } elseif (isset($c['generateCertificates'])) { - $new->setGenerateCertificates((bool)$c['generateCertificates']); + $new->setGenerateCertificates((bool) $c['generate_certificates']); + } + if (isset($c['generateCertificates'])) { + $new->setGenerateCertificates((bool) $c['generateCertificates']); } if (isset($c['certificate_validity_period'])) { - $new->setCertificateValidityPeriod((int)$c['certificate_validity_period']); - } elseif (isset($c['certificateValidityPeriod'])) { - $new->setCertificateValidityPeriod((int)$c['certificateValidityPeriod']); + $new->setCertificateValidityPeriod((int) $c['certificate_validity_period']); + } + if (isset($c['certificateValidityPeriod'])) { + $new->setCertificateValidityPeriod((int) $c['certificateValidityPeriod']); } if (isset($c['is_requirement'])) { - $new->setIsRequirement((bool)$c['is_requirement']); - } elseif (isset($c['isRequirement'])) { - $new->setIsRequirement((bool)$c['isRequirement']); + $new->setIsRequirement((bool) $c['is_requirement']); + } + if (isset($c['isRequirement'])) { + $new->setIsRequirement((bool) $c['isRequirement']); } if (isset($c['default_lowest_eval_exclude'])) { - $new->setDefaultLowestEvalExclude((bool)$c['default_lowest_eval_exclude']); - } elseif (isset($c['defaultLowestEvalExclude'])) { - $new->setDefaultLowestEvalExclude((bool)$c['defaultLowestEvalExclude']); + $new->setDefaultLowestEvalExclude((bool) $c['default_lowest_eval_exclude']); + } + if (isset($c['defaultLowestEvalExclude'])) { + $new->setDefaultLowestEvalExclude((bool) $c['defaultLowestEvalExclude']); + } + if (\array_key_exists('minimum_to_validate', $c)) { + $new->setMinimumToValidate((int) $c['minimum_to_validate']); } - if (array_key_exists('minimum_to_validate', $c)) { - $new->setMinimumToValidate((int)$c['minimum_to_validate']); - } elseif (array_key_exists('minimumToValidate', $c)) { - $new->setMinimumToValidate((int)$c['minimumToValidate']); + if (\array_key_exists('minimumToValidate', $c)) { + $new->setMinimumToValidate((int) $c['minimumToValidate']); } - if (array_key_exists('gradebooks_to_validate_in_dependence', $c)) { - $new->setGradeBooksToValidateInDependence((int)$c['gradebooks_to_validate_in_dependence']); - } elseif (array_key_exists('gradeBooksToValidateInDependence', $c)) { - $new->setGradeBooksToValidateInDependence((int)$c['gradeBooksToValidateInDependence']); + if (\array_key_exists('gradebooks_to_validate_in_dependence', $c)) { + $new->setGradeBooksToValidateInDependence((int) $c['gradebooks_to_validate_in_dependence']); } - if (array_key_exists('allow_skills_by_subcategory', $c)) { - $new->setAllowSkillsBySubcategory((int)$c['allow_skills_by_subcategory']); - } elseif (array_key_exists('allowSkillsBySubcategory', $c)) { - $new->setAllowSkillsBySubcategory((int)$c['allowSkillsBySubcategory']); + if (\array_key_exists('gradeBooksToValidateInDependence', $c)) { + $new->setGradeBooksToValidateInDependence((int) $c['gradeBooksToValidateInDependence']); + } + if (\array_key_exists('allow_skills_by_subcategory', $c)) { + $new->setAllowSkillsBySubcategory((int) $c['allow_skills_by_subcategory']); + } + if (\array_key_exists('allowSkillsBySubcategory', $c)) { + $new->setAllowSkillsBySubcategory((int) $c['allowSkillsBySubcategory']); } if (!empty($c['grade_model_id'])) { - $gm = $em->find(GradeModel::class, (int)$c['grade_model_id']); - if ($gm) { $new->setGradeModel($gm); } + $gm = $em->find(GradeModel::class, (int) $c['grade_model_id']); + if ($gm) { + $new->setGradeModel($gm); + } } $em->persist($new); @@ -4438,13 +5051,13 @@ public function restore_gradebook(int $sessionId = 0): void } } - // 3) Second pass: wire parents + // Second pass: wire category parents foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) { $categories = (array) ($gbItem->categories ?? []); foreach ($categories as $rawCat) { - $c = is_array($rawCat) ? $rawCat : (array) $rawCat; - $oldId = (int)($c['id'] ?? $c['iid'] ?? 0); - $parentOld = (int)($c['parent_id'] ?? $c['parentId'] ?? 0); + $c = \is_array($rawCat) ? $rawCat : (array) $rawCat; + $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0); + $parentOld = (int) ($c['parent_id'] ?? $c['parentId'] ?? 0); if ($oldId > 0 && isset($oldIdToNewCat[$oldId]) && $parentOld > 0 && isset($oldIdToNewCat[$parentOld])) { $cat = $oldIdToNewCat[$oldId]; $cat->setParent($oldIdToNewCat[$parentOld]); @@ -4454,70 +5067,98 @@ public function restore_gradebook(int $sessionId = 0): void } $em->flush(); - // 4) Evaluations + Links + // Evaluations and Links per category foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) { $categories = (array) ($gbItem->categories ?? []); foreach ($categories as $rawCat) { - $c = is_array($rawCat) ? $rawCat : (array) $rawCat; - $oldId = (int)($c['id'] ?? $c['iid'] ?? 0); - if ($oldId <= 0 || !isset($oldIdToNewCat[$oldId])) { continue; } + $c = \is_array($rawCat) ? $rawCat : (array) $rawCat; + $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0); + if ($oldId <= 0 || !isset($oldIdToNewCat[$oldId])) { + continue; + } $dstCat = $oldIdToNewCat[$oldId]; - // Evaluations - foreach ((array)($c['evaluations'] ?? []) as $rawEval) { - $e = is_array($rawEval) ? $rawEval : (array) $rawEval; - - $eval = new GradebookEvaluation(); - $eval->setCourse($courseEntity); - $eval->setCategory($dstCat); - $eval->setTitle((string)($e['title'] ?? 'Evaluation')); - $eval->setDescription((string)($e['description'] ?? '')); - $eval->setWeight((float)($e['weight'] ?? 0.0)); - $eval->setMax((float)($e['max'] ?? 100.0)); - $eval->setType((string)($e['type'] ?? 'manual')); - $eval->setVisible((int)($e['visible'] ?? 1)); - $eval->setLocked((int)($e['locked'] ?? 0)); - - if (isset($e['best_score'])) { $eval->setBestScore((float)$e['best_score']); } - if (isset($e['average_score'])) { $eval->setAverageScore((float)$e['average_score']); } - if (isset($e['score_weight'])) { $eval->setScoreWeight((float)$e['score_weight']); } - if (isset($e['min_score'])) { $eval->setMinScore((float)$e['min_score']); } + // Evaluations (rewrite description HTML) + foreach ((array) ($c['evaluations'] ?? []) as $rawEval) { + $e = \is_array($rawEval) ? $rawEval : (array) $rawEval; + + $evalDesc = (string) ($e['description'] ?? ''); + $evalDesc = $this->rewriteHtmlForCourse($evalDesc, (int) $sessionId, '[gradebook.evaluation]'); + + $eval = (new GradebookEvaluation()) + ->setCourse($courseEntity) + ->setCategory($dstCat) + ->setTitle((string) ($e['title'] ?? 'Evaluation')) + ->setDescription($evalDesc) + ->setWeight((float) ($e['weight'] ?? 0.0)) + ->setMax((float) ($e['max'] ?? 100.0)) + ->setType((string) ($e['type'] ?? 'manual')) + ->setVisible((int) ($e['visible'] ?? 1)) + ->setLocked((int) ($e['locked'] ?? 0)) + ; + + // Optional statistics fields + if (isset($e['best_score'])) { + $eval->setBestScore((float) $e['best_score']); + } + if (isset($e['average_score'])) { + $eval->setAverageScore((float) $e['average_score']); + } + if (isset($e['score_weight'])) { + $eval->setScoreWeight((float) $e['score_weight']); + } + if (isset($e['min_score'])) { + $eval->setMinScore((float) $e['min_score']); + } $em->persist($eval); } - // Links - foreach ((array)($c['links'] ?? []) as $rawLink) { - $l = is_array($rawLink) ? $rawLink : (array) $rawLink; + // Links to course tools (resolve destination IID for each) + foreach ((array) ($c['links'] ?? []) as $rawLink) { + $l = \is_array($rawLink) ? $rawLink : (array) $rawLink; - $linkType = (int)($l['type'] ?? $l['link_type'] ?? 0); - $legacyRef = (int)($l['ref_id'] ?? $l['refId'] ?? 0); + $linkType = (int) ($l['type'] ?? $l['link_type'] ?? 0); + $legacyRef = (int) ($l['ref_id'] ?? $l['refId'] ?? 0); if ($linkType <= 0 || $legacyRef <= 0) { $this->dlog('restore_gradebook: skipping link (missing type/ref)', $l); + continue; } + // Map link type → resource bucket, then resolve legacyId → newId $resourceType = $this->gb_guessResourceTypeByLinkType($linkType); - $newRefId = $this->gb_resolveDestinationId($resourceType, $legacyRef); + $newRefId = $this->gb_resolveDestinationId($resourceType, $legacyRef); if ($newRefId <= 0) { $this->dlog('restore_gradebook: skipping link (no destination id)', ['type' => $linkType, 'legacyRef' => $legacyRef]); + continue; } - $link = new GradebookLink(); - $link->setCourse($courseEntity); - $link->setCategory($dstCat); - $link->setType($linkType); - $link->setRefId($newRefId); - $link->setWeight((float)($l['weight'] ?? 0.0)); - $link->setVisible((int)($l['visible'] ?? 1)); - $link->setLocked((int)($l['locked'] ?? 0)); - - if (isset($l['best_score'])) { $link->setBestScore((float)$l['best_score']); } - if (isset($l['average_score'])) { $link->setAverageScore((float)$l['average_score']); } - if (isset($l['score_weight'])) { $link->setScoreWeight((float)$l['score_weight']); } - if (isset($l['min_score'])) { $link->setMinScore((float)$l['min_score']); } + $link = (new GradebookLink()) + ->setCourse($courseEntity) + ->setCategory($dstCat) + ->setType($linkType) + ->setRefId($newRefId) + ->setWeight((float) ($l['weight'] ?? 0.0)) + ->setVisible((int) ($l['visible'] ?? 1)) + ->setLocked((int) ($l['locked'] ?? 0)) + ; + + // Optional statistics fields + if (isset($l['best_score'])) { + $link->setBestScore((float) $l['best_score']); + } + if (isset($l['average_score'])) { + $link->setAverageScore((float) $l['average_score']); + } + if (isset($l['score_weight'])) { + $link->setScoreWeight((float) $l['score_weight']); + } + if (isset($l['min_score'])) { + $link->setMinScore((float) $l['min_score']); + } $em->persist($link); } @@ -4529,52 +5170,25 @@ public function restore_gradebook(int $sessionId = 0): void $this->dlog('restore_gradebook: done'); } - /** Map GradebookLink type → RESOURCE_* bucket used in $this->course->resources */ - private function gb_guessResourceTypeByLinkType(int $linkType): ?int - { - return match ($linkType) { - LINK_EXERCISE => RESOURCE_QUIZ, - LINK_STUDENTPUBLICATION => RESOURCE_WORK, - LINK_LEARNPATH => RESOURCE_LEARNPATH, - LINK_FORUM_THREAD => RESOURCE_FORUMTOPIC, - LINK_ATTENDANCE => RESOURCE_ATTENDANCE, - LINK_SURVEY => RESOURCE_SURVEY, - LINK_HOTPOTATOES => RESOURCE_QUIZ, - default => null, - }; - } - - /** Given a RESOURCE_* bucket and legacy id, return destination id (if that item was restored) */ - private function gb_resolveDestinationId(?int $type, int $legacyId): int - { - if (null === $type) { return 0; } - if (!$this->course->has_resources($type)) { return 0; } - $bucket = $this->course->resources[$type] ?? []; - if (!isset($bucket[$legacyId])) { return 0; } - $res = $bucket[$legacyId]; - $destId = (int)($res->destination_id ?? 0); - return $destId > 0 ? $destId : 0; - } - - /** * Restore course assets (not included in documents). */ - public function restore_assets() + public function restore_assets(): void { if ($this->course->has_resources(RESOURCE_ASSET)) { $resources = $this->course->resources; $path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/'; foreach ($resources[RESOURCE_ASSET] as $asset) { - if (is_file($this->course->backup_path.'/'.$asset->path) && - is_readable($this->course->backup_path.'/'.$asset->path) && - is_dir(dirname($path.$asset->path)) && - is_writeable(dirname($path.$asset->path)) + if (is_file($this->course->backup_path.'/'.$asset->path) + && is_readable($this->course->backup_path.'/'.$asset->path) + && is_dir(\dirname($path.$asset->path)) + && is_writable(\dirname($path.$asset->path)) ) { switch ($this->file_option) { case FILE_SKIP: break; + case FILE_OVERWRITE: copy( $this->course->backup_path.'/'.$asset->path, @@ -4587,4 +5201,661 @@ public function restore_assets() } } } + + /** + * Get all resources from snapshot or live course object. + * + * @return array + */ + public function getAllResources(): array + { + // Prefer the previously captured snapshot if present; otherwise fall back to current course->resources + return !empty($this->resources_all_snapshot) + ? $this->resources_all_snapshot + : (array) ($this->course->resources ?? []); + } + + /** + * Back-fill empty dependency bags from the snapshot into $this->course->resources. + */ + private function ensureDepsBagsFromSnapshot(): void + { + // Read the authoritative set of resources (snapshot or live) + $all = $this->getAllResources(); + + // Reference the course resources by reference to update in place + $c = &$this->course->resources; + + // Ensure these resource bags exist; if missing/empty, copy them from snapshot + foreach (['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'] as $k) { + $cur = $c[$k] ?? []; + if ((!\is_array($cur) || 0 === \count($cur)) && !empty($all[$k]) && \is_array($all[$k])) { + // Back-fill from snapshot to keep dependencies consistent + $c[$k] = $all[$k]; + } + } + } + + /** + * Rewrite HTML content so legacy course URLs point to destination course documents. + * + * Returns the (possibly) rewritten HTML. + */ + private function rewriteHtmlForCourse(string $html, int $sessionId, string $dbgTag = ''): string + { + // Nothing to do if the HTML is empty + if ('' === $html) { + return ''; + } + + // Resolve context entities (course/session/group) and repositories + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int) $sessionId); + $group = api_get_group_entity(); + $docRepo = Container::getDocumentRepository(); + + // Determine course directory and source root (when importing from a ZIP/package) + $courseDir = (string) ($this->course->info['path'] ?? ''); + $srcRoot = rtrim((string) ($this->course->backup_path ?? ''), '/'); + + // Cache of created folder IIDs per course dir to avoid duplicate folder creation + if (!isset($this->htmlFoldersByCourseDir[$courseDir])) { + $this->htmlFoldersByCourseDir[$courseDir] = []; + } + $folders = &$this->htmlFoldersByCourseDir[$courseDir]; + + // Small debug helper bound to the current dbgTag + $DBG = function (string $tag, array $ctx = []) use ($dbgTag): void { + $this->dlog('HTMLRW'.$dbgTag.': '.$tag, $ctx); + }; + + // Ensure a folder chain exists under /document and return parent IID (0 means root) + $ensureFolder = function (string $relPath) use (&$folders, $course, $session, $DBG) { + // Ignore empty/root markers + if ('/' === $relPath || '/document' === $relPath) { + return 0; + } + + // Reuse cached IID if we already created/resolved this path + if (!empty($folders[$relPath])) { + return (int) $folders[$relPath]; + } + + try { + // Create the folder via DocumentManager; parent is resolved by the path + $entity = DocumentManager::addDocument( + ['real_id' => $course->getId(), 'code' => method_exists($course, 'getCode') ? $course->getCode() : null], + $relPath, + 'folder', + 0, + basename($relPath), + null, + 0, + null, + 0, + (int) ($session?->getId() ?? 0) + ); + + // Cache the created IID if available + $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; + if ($iid > 0) { + $folders[$relPath] = $iid; + } + + return $iid; + } catch (Throwable $e) { + // Do not interrupt restore flow if folder creation fails + $DBG('ensureFolder.error', ['relPath' => $relPath, 'err' => $e->getMessage()]); + + return 0; + } + }; + + // Only rewrite when we are importing from a package (ZIP) with a known source root + if ('' !== $srcRoot) { + try { + // Build a URL map for all legacy references found in the HTML + $mapDoc = ChamiloHelper::buildUrlMapForHtmlFromPackage( + $html, + $courseDir, + $srcRoot, + $folders, + $ensureFolder, + $docRepo, + $course, + $session, + $group, + (int) $sessionId, + (int) $this->file_option, + $DBG + ); + + // Rewrite the HTML using both exact (byRel) and basename (byBase) maps + $rr = ChamiloHelper::rewriteLegacyCourseUrlsWithMap( + $html, + $courseDir, + $mapDoc['byRel'] ?? [], + $mapDoc['byBase'] ?? [] + ); + + // Log replacement stats for troubleshooting + $DBG('zip.rewrite', ['replaced' => $rr['replaced'] ?? 0, 'misses' => $rr['misses'] ?? 0]); + + // Return rewritten HTML when available; otherwise the original + return (string) ($rr['html'] ?? $html); + } catch (Throwable $e) { + // Fall back to original HTML if anything fails during mapping/rewrite + $DBG('zip.error', ['err' => $e->getMessage()]); + + return $html; + } + } + + // If no package source root, return the original HTML unchanged + return $html; + } + + /** + * Centralized logger controlled by $this->debug. + */ + private function dlog(string $message, array $context = []): void + { + if (!$this->debug) { + return; + } + $ctx = ''; + if (!empty($context)) { + try { + $ctx = ' '.json_encode( + $context, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR + ); + } catch (Throwable $e) { + $ctx = ' [context_json_failed: '.$e->getMessage().']'; + } + } + error_log('COURSE_DEBUG: '.$message.$ctx); + } + + /** + * Public setter for the debug flag. + */ + public function setDebug(?bool $on = true): void + { + $this->debug = (bool) $on; + $this->dlog('Debug flag changed', ['debug' => $this->debug]); + } + + /** + * Given a RESOURCE_* bucket and legacy id, return destination id (if that item was restored). + */ + private function gb_resolveDestinationId(?int $type, int $legacyId): int + { + if (null === $type) { + return 0; + } + if (!$this->course->has_resources($type)) { + return 0; + } + $bucket = $this->course->resources[$type] ?? []; + if (!isset($bucket[$legacyId])) { + return 0; + } + $res = $bucket[$legacyId]; + $destId = (int) ($res->destination_id ?? 0); + + return $destId > 0 ? $destId : 0; + } + + /** + * Map GradebookLink type → RESOURCE_* bucket used in $this->course->resources. + */ + private function gb_guessResourceTypeByLinkType(int $linkType): ?int + { + return match ($linkType) { + LINK_EXERCISE => RESOURCE_QUIZ, + LINK_STUDENTPUBLICATION => RESOURCE_WORK, + LINK_LEARNPATH => RESOURCE_LEARNPATH, + LINK_FORUM_THREAD => RESOURCE_FORUMTOPIC, + LINK_ATTENDANCE => RESOURCE_ATTENDANCE, + LINK_SURVEY => RESOURCE_SURVEY, + LINK_HOTPOTATOES => RESOURCE_QUIZ, + default => null, + }; + } + + /** + * Add this setter to forward the full resources snapshot from the controller. + */ + public function setResourcesAllSnapshot(array $snapshot): void + { + // Keep a private property like $this->resources_all_snapshot + // (declare it if you don't have it: private array $resources_all_snapshot = [];) + $this->resources_all_snapshot = $snapshot; + $this->dlog('Restorer: all-resources snapshot injected', [ + 'keys' => array_keys($snapshot), + ]); + } + + /** + * Zip a SCORM folder (must contain imsmanifest.xml) into a temp ZIP. + * Returns absolute path to the temp ZIP or null on error. + */ + private function zipScormFolder(string $folderAbs): ?string + { + $folderAbs = rtrim($folderAbs, '/'); + $manifest = $folderAbs.'/imsmanifest.xml'; + if (!is_file($manifest)) { + error_log("SCORM ZIPPER: 'imsmanifest.xml' not found in folder: $folderAbs"); + + return null; + } + + $tmpZip = sys_get_temp_dir().'/scorm_'.uniqid('', true).'.zip'; + + try { + $zip = new ZipFile(); + // Put folder contents at the ZIP root – important for SCORM imports + $zip->addDirRecursive($folderAbs, ''); + $zip->saveAsFile($tmpZip); + $zip->close(); + } catch (Throwable $e) { + error_log('SCORM ZIPPER: Failed to create temp zip: '.$e->getMessage()); + + return null; + } + + if (!is_file($tmpZip) || 0 === filesize($tmpZip)) { + @unlink($tmpZip); + error_log("SCORM ZIPPER: Temp zip is empty or missing: $tmpZip"); + + return null; + } + + return $tmpZip; + } + + /** + * Find a SCORM package for a given LP. + * It returns ['zip' => , 'temp' => true if zip is temporary]. + * + * Search order: + * 1) resources[SCORM] entries bound to this LP (zip or path). + * - If 'path' is a folder containing imsmanifest.xml, it will be zipped on the fly. + * 2) Heuristics: scan typical folders for *.zip + * 3) Heuristics: scan backup recursively for an imsmanifest.xml, then zip that folder. + */ + private function findScormPackageForLp(int $srcLpId): array + { + $out = ['zip' => null, 'temp' => false]; + $base = rtrim($this->course->backup_path, '/'); + + // 1) Direct mapping from SCORM bucket + if (!empty($this->course->resources[RESOURCE_SCORM]) && \is_array($this->course->resources[RESOURCE_SCORM])) { + foreach ($this->course->resources[RESOURCE_SCORM] as $sc) { + $src = isset($sc->source_lp_id) ? (int) $sc->source_lp_id : 0; + $dst = isset($sc->lp_id_dest) ? (int) $sc->lp_id_dest : 0; + $match = ($src && $src === $srcLpId); + + if ( + !$match + && $dst + && !empty($this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id) + ) { + $match = ($dst === (int) $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id); + } + if (!$match) { + continue; + } + + $cands = []; + if (!empty($sc->zip)) { + $cands[] = $base.'/'.ltrim((string) $sc->zip, '/'); + } + if (!empty($sc->path)) { + $cands[] = $base.'/'.ltrim((string) $sc->path, '/'); + } + + foreach ($cands as $abs) { + if (is_file($abs) && is_readable($abs)) { + $out['zip'] = $abs; + $out['temp'] = false; + + return $out; + } + if (is_dir($abs) && is_readable($abs)) { + $tmp = $this->zipScormFolder($abs); + if ($tmp) { + $out['zip'] = $tmp; + $out['temp'] = true; + + return $out; + } + } + } + } + } + + // 2) Heuristic: typical folders with *.zip + foreach (['/scorm', '/document/scorm', '/documents/scorm'] as $dir) { + $full = $base.$dir; + if (!is_dir($full)) { + continue; + } + $glob = glob($full.'/*.zip') ?: []; + if (!empty($glob)) { + $out['zip'] = $glob[0]; + $out['temp'] = false; + + return $out; + } + } + + // 3) Heuristic: look for imsmanifest.xml anywhere, then zip that folder + $riiFlags = FilesystemIterator::SKIP_DOTS; + + try { + $rii = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, $riiFlags), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($rii as $f) { + if ($f->isFile() && 'imsmanifest.xml' === strtolower($f->getFilename())) { + $folder = $f->getPath(); + $tmp = $this->zipScormFolder($folder); + if ($tmp) { + $out['zip'] = $tmp; + $out['temp'] = true; + + return $out; + } + } + } + } catch (Throwable $e) { + error_log('SCORM FINDER: Recursive scan failed: '.$e->getMessage()); + } + + return $out; + } + + /** + * Check if a survey code is available. + * + * @param mixed $survey_code + * + * @return bool + */ + public function is_survey_code_available($survey_code) + { + $survey_code = (string) $survey_code; + $surveyRepo = Container::getSurveyRepository(); + + try { + // If a survey with this code exists, it's not available + $hit = $surveyRepo->findOneBy(['code' => $survey_code]); + + return $hit ? false : true; + } catch (Throwable $e) { + // Fallback to "available" on repository failure + $this->debug && error_log('COURSE_DEBUG: is_survey_code_available: fallback failed: '.$e->getMessage()); + + return true; + } + } + + /** + * Resolve absolute filesystem path for an announcement attachment. + */ + private function resourceFileAbsPathFromAnnouncementAttachment(CAnnouncementAttachment $att): ?string + { + // Get the resource node linked to this attachment + $node = $att->getResourceNode(); + if (!$node) { + return null; // No node, nothing to resolve + } + + // Get the first physical resource file + $file = $node->getFirstResourceFile(); + if (!$file) { + return null; // No physical file bound + } + + /** @var ResourceNodeRepository $rnRepo */ + $rnRepo = Container::$container->get(ResourceNodeRepository::class); + + // Relative path stored by the repository + $rel = $rnRepo->getFilename($file); + if (!$rel) { + return null; // Missing relative path + } + + // Compose absolute path inside the project upload base + $abs = $this->projectUploadBase().$rel; + + // Return only if readable to avoid runtime errors + return is_readable($abs) ? $abs : null; + } + + /** + * Compact dump of resources: keys, per-bag counts and one sample (trimmed). + */ + private function debug_course_resources_simple(?string $focusBag = null, int $maxObjFields = 10): void + { + try { + $resources = \is_array($this->course->resources ?? null) ? $this->course->resources : []; + + $safe = function ($data): string { + try { + return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[json_encode_failed]'; + } catch (Throwable $e) { + return '[json_exception: '.$e->getMessage().']'; + } + }; + $short = function ($v, int $max = 200) { + if (\is_string($v)) { + $s = trim($v); + + return mb_strlen($s) > $max ? (mb_substr($s, 0, $max).'…('.mb_strlen($s).' chars)') : $s; + } + if (is_numeric($v) || \is_bool($v) || null === $v) { + return $v; + } + + return '['.\gettype($v).']'; + }; + $sample = function ($item) use ($short, $maxObjFields) { + $out = [ + 'source_id' => null, + 'destination_id' => null, + 'type' => null, + 'has_obj' => false, + 'obj_fields' => [], + 'has_item_props' => false, + 'extra' => [], + ]; + if (\is_object($item) || \is_array($item)) { + $arr = (array) $item; + $out['source_id'] = $arr['source_id'] ?? null; + $out['destination_id'] = $arr['destination_id'] ?? null; + $out['type'] = $arr['type'] ?? null; + $out['has_item_props'] = !empty($arr['item_properties']); + + $obj = $arr['obj'] ?? null; + if (\is_object($obj) || \is_array($obj)) { + $out['has_obj'] = true; + $objArr = (array) $obj; + $fields = []; + $i = 0; + foreach ($objArr as $k => $v) { + if ($i++ >= $maxObjFields) { + $fields['__notice'] = 'truncated'; + + break; + } + $fields[$k] = $short($v); + } + $out['obj_fields'] = $fields; + } + foreach (['path', 'title', 'comment'] as $k) { + if (isset($arr[$k])) { + $out['extra'][$k] = $short($arr[$k]); + } + } + } else { + $out['extra']['_type'] = \gettype($item); + } + + return $out; + }; + + $this->dlog('Resources overview', ['keys' => array_keys($resources)]); + + foreach ($resources as $bagName => $bag) { + if (!\is_array($bag)) { + $this->dlog('Bag not an array, skipping', ['bag' => $bagName, 'type' => \gettype($bag)]); + + continue; + } + $count = \count($bag); + $this->dlog('Bag count', ['bag' => $bagName, 'count' => $count]); + + if ($count > 0) { + $firstKey = array_key_first($bag); + $firstVal = $bag[$firstKey]; + $s = $sample($firstVal); + $s['__first_key'] = $firstKey; + $s['__class'] = \is_object($firstVal) ? $firstVal::class : \gettype($firstVal); + $this->dlog('Bag sample', ['bag' => $bagName, 'sample' => $s]); + } + + if (null !== $focusBag && $focusBag === $bagName) { + $preview = []; + $i = 0; + foreach ($bag as $k => $v) { + if ($i++ >= 10) { + $preview[] = ['__notice' => 'truncated-after-10-items']; + + break; + } + $preview[] = ['key' => $k, 'sample' => $sample($v)]; + } + $this->dlog('Bag deep preview', ['bag' => $bagName, 'items' => $preview]); + } + } + } catch (Throwable $e) { + $this->dlog('Failed to dump resources', ['error' => $e->getMessage()]); + } + } + + /** + * Get absolute base path where ResourceFiles are stored in the project. + */ + private function projectUploadBase(): string + { + /** @var KernelInterface $kernel */ + $kernel = Container::$container->get('kernel'); + + // Resource uploads live under var/upload/resource (Symfony project dir) + return rtrim($kernel->getProjectDir(), '/').'/var/upload/resource'; + } + + /** + * Resolve the absolute file path for a CDocument's first ResourceFile, if readable. + */ + private function resourceFileAbsPathFromDocument(CDocument $doc): ?string + { + // Each CDocument references a ResourceNode; bail out if missing + $node = $doc->getResourceNode(); + if (!$node) { + return null; + } + + // Use the first ResourceFile attached to the node + $file = $node->getFirstResourceFile(); + if (!$file) { + return null; + } + + /** @var ResourceNodeRepository $rnRepo */ + $rnRepo = Container::$container->get(ResourceNodeRepository::class); + + // Repository provides the relative path for the resource file + $rel = $rnRepo->getFilename($file); + if (!$rel) { + return null; + } + + // Compose absolute path and validate readability + $abs = $this->projectUploadBase().$rel; + + return is_readable($abs) ? $abs : null; + } + + /** + * Normalize forum keys so internal bags are always available. + */ + private function normalizeForumKeys(): void + { + if (!\is_array($this->course->resources ?? null)) { + $this->course->resources = []; + + return; + } + $r = $this->course->resources; + + // Categories + if (!isset($r['Forum_Category']) && isset($r['forum_category'])) { + $r['Forum_Category'] = $r['forum_category']; + } + + // Forums + if (!isset($r['forum']) && isset($r['Forum'])) { + $r['forum'] = $r['Forum']; + } + + // Topics + if (!isset($r['thread']) && isset($r['forum_topic'])) { + $r['thread'] = $r['forum_topic']; + } elseif (!isset($r['thread']) && isset($r['Forum_Thread'])) { + $r['thread'] = $r['Forum_Thread']; + } + + // Posts + if (!isset($r['post']) && isset($r['forum_post'])) { + $r['post'] = $r['forum_post']; + } elseif (!isset($r['post']) && isset($r['Forum_Post'])) { + $r['post'] = $r['Forum_Post']; + } + + $this->course->resources = $r; + $this->dlog('Forum keys normalized', [ + 'has_Forum_Category' => isset($r['Forum_Category']), + 'forum_count' => isset($r['forum']) && \is_array($r['forum']) ? \count($r['forum']) : 0, + 'thread_count' => isset($r['thread']) && \is_array($r['thread']) ? \count($r['thread']) : 0, + 'post_count' => isset($r['post']) && \is_array($r['post']) ? \count($r['post']) : 0, + ]); + } + + /** + * Reset Doctrine if the EntityManager is closed; otherwise clear it. + */ + private function resetDoctrineIfClosed(): void + { + try { + // Get the current EntityManager + $em = Database::getManager(); + + if (!$em->isOpen()) { + // If closed, reset the manager to recover from fatal transaction errors + $registry = Container::$container->get('doctrine'); + $registry->resetManager(); + } else { + // If open, just clear to free managed entities and avoid memory leaks + $em->clear(); + } + } catch (Throwable $e) { + // Never break the flow due to maintenance logic + error_log('COURSE_DEBUG: resetDoctrineIfClosed failed: '.$e->getMessage()); + } + } } From 854c8158ffd387a976c713f373ca8fa8355b9220 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Fri, 10 Oct 2025 01:12:26 -0500 Subject: [PATCH 2/2] Course: Minor: cleaning code with phpcs - refs #6870 --- .../CourseMaintenanceController.php | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php index 01fecafae8b..6f645f31a05 100644 --- a/src/CoreBundle/Controller/CourseMaintenanceController.php +++ b/src/CoreBundle/Controller/CourseMaintenanceController.php @@ -1965,24 +1965,19 @@ private function filterLegacyCourseBySelection(object $course, array $selected): } } - // --- Añadir carpetas padre de los documentos seleccionados --- - // (para preservar estructura aunque el usuario no marque las carpetas) $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']); if ($docKey && !empty($keep[$docKey])) { $docBucket = $getBucket($orig, $docKey); - // Indexar carpetas por su ruta relativa a "document/" $foldersByRel = []; foreach ($docBucket as $fid => $res) { $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; - - // Detectar folder (por file_type o por path con "/") $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); $isFolder = ('folder' === $ftRaw); if (!$isFolder) { $pTest = (string) ($e->path ?? ''); if ('' !== $pTest) { - $isFolder = ('/' === substr($pTest, -1)); // ej: "document/folder-001/" + $isFolder = ('/' === substr($pTest, -1)); } } if (!$isFolder) { @@ -1994,7 +1989,6 @@ private function filterLegacyCourseBySelection(object $course, array $selected): continue; } - // Relativo a "document/…" $frel = '/'.ltrim(substr($p, 8), '/'); $frel = rtrim($frel, '/').'/'; if ('//' !== $frel) { @@ -2002,12 +1996,10 @@ private function filterLegacyCourseBySelection(object $course, array $selected): } } - // Determinar carpetas necesarias para cada archivo ya seleccionado $needFolderIds = []; foreach ($keep[$docKey] as $id => $res) { $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; - // Si es carpeta ya está incluida $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); $isFolder = ('folder' === $ftRaw) || ('/' === substr((string) ($e->path ?? ''), -1)); if ($isFolder) { @@ -2019,14 +2011,12 @@ private function filterLegacyCourseBySelection(object $course, array $selected): continue; } - // "/subdir/…" relativo a "document/" $rel = '/'.ltrim(substr($p, 8), '/'); $dir = rtrim(\dirname($rel), '/'); if ('' === $dir) { continue; - } // archivo en raíz + } - // Subir por todos los ancestros y marcarlos si existen en el bucket $acc = ''; foreach (array_filter(explode('/', $dir)) as $seg) { $acc .= '/'.$seg; @@ -2048,14 +2038,12 @@ private function filterLegacyCourseBySelection(object $course, array $selected): } } - // --- Links → categorías usadas --- $lnkKey = $this->firstExistingKey( $orig, ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : ''] ); if ($lnkKey && !empty($keep[$lnkKey])) { - // IDs de categorías realmente usadas por los links seleccionados $catIdsUsed = []; foreach ($keep[$lnkKey] as $lid => $lWrap) { $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap; @@ -2065,7 +2053,6 @@ private function filterLegacyCourseBySelection(object $course, array $selected): } } - // Busca el bucket original tal cual venga en el backup $catKey = $this->firstExistingKey( $orig, ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : ''] @@ -2074,14 +2061,8 @@ private function filterLegacyCourseBySelection(object $course, array $selected): if ($catKey && !empty($catIdsUsed)) { $catBucket = $getBucket($orig, $catKey); if (!empty($catBucket)) { - // Subconjunto de categorías realmente referenciadas $subset = array_intersect_key($catBucket, $catIdsUsed); - - // 1) Conserva el nombre ORIGINAL del bucket (sin renombrar) $keep[$catKey] = $subset; - - // 2) Además, crea un espejo bajo 'link_category' por compatibilidad - // (esto evita problemas si el restorer espera esta clave) $keep['link_category'] = $subset; $this->logDebug('[filterSelection] pulled link categories for selected links', [ @@ -2237,7 +2218,7 @@ private function snapshotForumCounts(object $course): array * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.). * - * @param object $course Legacy Course with already hydrated resources + * @param object $course Legacy Course with already hydrated resources * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath']) * * @return array>