+
{{ emptyText }}
-
+
toggleNode(g, val)"
+ @select-group="(val) => toggleNode(g, val)"
/>
@@ -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 }}%
+
-
-
-
-
- {{ 'Thematic' | trans }} |
- {{ 'Thematic plan' | trans }} |
- {{ 'Thematic advance' | trans }} |
-
-
-
- {% for item in data %}
-
-
- {% 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 }}
+
+
+
+
+
- {% if item.plans is empty %}
-
- {{ 'There is no thematic plan for now' | trans }}
-
- {% 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 %}
-
-
- |
-
- {% endfor %}
-
-
+
+
+
+ {% endfor %}
-
-
-{% else %}
- {% if is_allowed_to_edit %}
- {{ no_data }}
{% else %}
-
{{ 'There is no thematic section' | trans }}
+ {% 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..6f645f31a05 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,119 @@ private function filterLegacyCourseBySelection(object $course, array $selected):
}
}
+ $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
+ if ($docKey && !empty($keep[$docKey])) {
+ $docBucket = $getBucket($orig, $docKey);
+
+ $foldersByRel = [];
+ foreach ($docBucket as $fid => $res) {
+ $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
+ $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
+ $isFolder = ('folder' === $ftRaw);
+ if (!$isFolder) {
+ $pTest = (string) ($e->path ?? '');
+ if ('' !== $pTest) {
+ $isFolder = ('/' === substr($pTest, -1));
+ }
+ }
+ if (!$isFolder) {
+ continue;
+ }
+
+ $p = (string) ($e->path ?? '');
+ if ('' === $p) {
+ continue;
+ }
+
+ $frel = '/'.ltrim(substr($p, 8), '/');
+ $frel = rtrim($frel, '/').'/';
+ if ('//' !== $frel) {
+ $foldersByRel[$frel] = $fid;
+ }
+ }
+
+ $needFolderIds = [];
+ foreach ($keep[$docKey] as $id => $res) {
+ $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
+
+ $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;
+ }
+
+ $rel = '/'.ltrim(substr($p, 8), '/');
+ $dir = rtrim(\dirname($rel), '/');
+ if ('' === $dir) {
+ continue;
+ }
+
+ $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),
+ ]);
+ }
+ }
+
+ $lnkKey = $this->firstExistingKey(
+ $orig,
+ ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']
+ );
+
+ if ($lnkKey && !empty($keep[$lnkKey])) {
+ $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;
+ }
+ }
+
+ $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)) {
+ $subset = array_intersect_key($catBucket, $catIdsUsed);
+ $keep[$catKey] = $subset;
+ $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 +2212,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());
+ }
+ }
}