| Total Complexity | 868 |
| Total Lines | 4239 |
| Duplicated Lines | 0 % |
| Changes | 2 | ||
| Bugs | 0 | Features | 1 |
Complex classes like MoodleImport often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use MoodleImport, and based on these observations, apply Extract Interface, too.
| 1 | <?php |
||
| 40 | class MoodleImport |
||
| 41 | { |
||
| 42 | private ?string $ctxArchivePath = null; |
||
| 43 | private ?EntityManagerInterface $ctxEm = null; |
||
| 44 | private int $ctxCourseRealId = 0; |
||
| 45 | private int $ctxSessionId = 0; |
||
| 46 | private ?int $ctxSameFileNameOption = null; |
||
| 47 | |||
| 48 | public function __construct(private bool $debug = false) {} |
||
| 49 | |||
| 50 | public function attachContext( |
||
| 51 | string $archivePath, |
||
| 52 | EntityManagerInterface $em, |
||
| 53 | int $courseRealId, |
||
| 54 | int $sessionId = 0, |
||
| 55 | ?int $sameFileNameOption = null |
||
| 56 | ): self { |
||
| 57 | $this->ctxArchivePath = $archivePath; |
||
| 58 | $this->ctxEm = $em; |
||
| 59 | $this->ctxCourseRealId = $courseRealId; |
||
| 60 | $this->ctxSessionId = $sessionId; |
||
| 61 | $this->ctxSameFileNameOption = $sameFileNameOption; |
||
| 62 | |||
| 63 | return $this; |
||
| 64 | } |
||
| 65 | |||
| 66 | /** |
||
| 67 | * Builds a Course ready for CourseRestorer::restore(). |
||
| 68 | */ |
||
| 69 | public function buildLegacyCourseFromMoodleArchive(string $archivePath): object |
||
| 70 | { |
||
| 71 | $rid = \function_exists('random_bytes') ? substr(bin2hex(random_bytes(3)), 0, 6) : substr(sha1((string) mt_rand()), 0, 6); |
||
| 72 | if ($this->debug) { error_log("MBZ[$rid] START buildLegacyCourseFromMoodleArchive archivePath={$archivePath}"); } |
||
| 73 | |||
| 74 | // 1) Extract archive to a temp working directory |
||
| 75 | [$workDir] = $this->extractToTemp($archivePath); |
||
| 76 | if ($this->debug) { error_log("MBZ[$rid] extracted workDir={$workDir}"); } |
||
| 77 | |||
| 78 | $mbx = $workDir.'/moodle_backup.xml'; |
||
| 79 | if (!is_file($mbx)) { |
||
| 80 | if ($this->debug) { error_log("MBZ[$rid] ERROR moodle_backup.xml missing at {$mbx}"); } |
||
| 81 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 82 | } |
||
| 83 | |||
| 84 | // Optional: files.xml for documents/resources |
||
| 85 | $fx = $workDir.'/files.xml'; |
||
| 86 | $hasFilesXml = is_file($fx); |
||
| 87 | $fileIndex = $hasFilesXml ? $this->buildFileIndex($fx, $workDir) : ['byId' => [], 'byHash' => []]; |
||
| 88 | if ($this->debug) { |
||
| 89 | $byId = isset($fileIndex['byId']) ? count((array) $fileIndex['byId']) : 0; |
||
| 90 | $byHash= isset($fileIndex['byHash']) ? count((array) $fileIndex['byHash']) : 0; |
||
| 91 | error_log("MBZ[$rid] indexes moodle_backup.xml=1 files.xml=".($hasFilesXml?1:0)." fileIndex.byId={$byId} fileIndex.byHash={$byHash}"); |
||
| 92 | } |
||
| 93 | |||
| 94 | // 2) Load main XMLs |
||
| 95 | $mbDoc = $this->loadXml($mbx); |
||
| 96 | $mb = new DOMXPath($mbDoc); |
||
| 97 | |||
| 98 | // Detect meta sidecars early to drive import policy |
||
| 99 | $hasQuizMeta = $this->hasQuizMeta($workDir); |
||
| 100 | $hasLpMeta = $this->hasLearnpathMeta($workDir); |
||
| 101 | if ($this->debug) { error_log("MBZ[$rid] meta_flags hasQuizMeta=".($hasQuizMeta?1:0)." hasLpMeta=".($hasLpMeta?1:0)); } |
||
| 102 | |||
| 103 | $skippedQuizXml = 0; // stats |
||
| 104 | |||
| 105 | // Optional course.xml (course meta, summary) |
||
| 106 | $courseXmlPath = $workDir.'/course/course.xml'; |
||
| 107 | $courseMeta = $this->readCourseMeta($courseXmlPath); // NEW: safe, tolerant |
||
| 108 | if ($this->debug) { |
||
| 109 | $cm = array_intersect_key((array)$courseMeta, array_flip(['fullname','shortname','idnumber','format'])); |
||
| 110 | error_log("MBZ[$rid] course_meta ".json_encode($cm, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 111 | } |
||
| 112 | |||
| 113 | // 3) Read sections & pre-build LP map (one LP per section) |
||
| 114 | $sections = $this->readSections($mb); |
||
| 115 | if ($this->debug) { error_log("MBZ[$rid] sections.count=".count((array)$sections)); } |
||
| 116 | $lpMap = $this->sectionsToLearnpaths($sections); |
||
| 117 | |||
| 118 | // 4) Init resource buckets (legacy snapshot shape) |
||
| 119 | $resources = [ |
||
| 120 | 'document' => [], |
||
| 121 | 'Forum_Category' => [], |
||
| 122 | 'forum' => [], |
||
| 123 | 'Link_Category' => [], |
||
| 124 | 'link' => [], |
||
| 125 | 'learnpath' => [], |
||
| 126 | 'learnpath_category' => [], |
||
| 127 | 'scorm_documents' => [], |
||
| 128 | 'scorm' => [], |
||
| 129 | 'announcement' => [], |
||
| 130 | 'course_descriptions' => [], |
||
| 131 | 'tool_intro' => [], |
||
| 132 | 'events' => [], |
||
| 133 | 'quizzes' => [], |
||
| 134 | 'quiz_question' => [], |
||
| 135 | 'surveys' => [], |
||
| 136 | 'works' => [], |
||
| 137 | 'glossary' => [], |
||
| 138 | 'wiki' => [], |
||
| 139 | 'gradebook' => [], |
||
| 140 | 'assets' => [], |
||
| 141 | 'attendance' => [], |
||
| 142 | ]; |
||
| 143 | |||
| 144 | // 5) Ensure a default Forum Category (fallback) |
||
| 145 | $defaultForumCatId = 1; |
||
| 146 | $resources['Forum_Category'][$defaultForumCatId] = $this->mkLegacyItem('Forum_Category', $defaultForumCatId, [ |
||
| 147 | 'id' => $defaultForumCatId, |
||
| 148 | 'cat_title' => 'General', |
||
| 149 | 'cat_comment' => '', |
||
| 150 | 'title' => 'General', |
||
| 151 | 'description' => '', |
||
| 152 | ]); |
||
| 153 | if ($this->debug) { error_log("MBZ[$rid] forum.default_category id={$defaultForumCatId}"); } |
||
| 154 | |||
| 155 | // 6) Ensure document working dirs |
||
| 156 | $this->ensureDir($workDir.'/document'); |
||
| 157 | $this->ensureDir($workDir.'/document/moodle_pages'); |
||
| 158 | |||
| 159 | // Root folder example (kept for consistency; optional) |
||
| 160 | if (empty($resources['document'])) { |
||
| 161 | $docFolderId = $this->nextId($resources['document']); |
||
| 162 | $resources['document'][$docFolderId] = $this->mkLegacyItem('document', $docFolderId, [ |
||
| 163 | 'file_type' => 'folder', |
||
| 164 | 'path' => '/document/moodle_pages', |
||
| 165 | 'title' => 'moodle_pages', |
||
| 166 | ]); |
||
| 167 | if ($this->debug) { error_log("MBZ[$rid] document.root_folder id={$docFolderId} path=/document/moodle_pages"); } |
||
| 168 | } |
||
| 169 | |||
| 170 | // 7) Iterate activities and fill buckets |
||
| 171 | $activityNodes = $mb->query('//activity'); |
||
| 172 | $activityCount = $activityNodes?->length ?? 0; |
||
| 173 | if ($this->debug) { error_log("MBZ[$rid] activities.count total={$activityCount}"); } |
||
| 174 | $i = 0; // contador interno (punto en property names no permitido, usar otra var) |
||
| 175 | $i = 0; |
||
| 176 | |||
| 177 | foreach ($activityNodes as $node) { |
||
| 178 | /** @var DOMElement $node */ |
||
| 179 | $i++; |
||
| 180 | $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); |
||
| 181 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 182 | $sectionId = (int) ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); |
||
| 183 | |||
| 184 | if ($this->debug) { error_log("MBZ[$rid] activity #{$i} mod={$modName} dir={$dir} section={$sectionId}"); } |
||
| 185 | |||
| 186 | // Locate module xml path |
||
| 187 | $moduleXml = ('' !== $modName && '' !== $dir) ? $workDir.'/'.$dir.'/'.$modName.'.xml' : null; |
||
| 188 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 189 | // Some modules use different file names (resource, folder...) – handled separately |
||
| 190 | if ($this->debug) { error_log("MBZ[$rid] activity #{$i} skip={$modName} reason=module_xml_not_found"); } |
||
| 191 | } |
||
| 192 | |||
| 193 | // --- Early mapping: Moodle forum(type=news) -> Chamilo announcements (skip normal forum path) |
||
| 194 | if ($moduleXml && is_file($moduleXml) && strtolower((string)$modName) === 'forum') { |
||
| 195 | if ($this->hasChamiloAnnouncementMeta($workDir)) { |
||
| 196 | if ($this->debug) { error_log("MBZ[$rid] forum early-map: announcements meta present -> keep non-news, skip news"); } |
||
| 197 | } else { |
||
| 198 | $forumInfo = $this->readForumHeader($moduleXml); |
||
| 199 | if ($this->isNewsForum($forumInfo)) { |
||
| 200 | if ($this->debug) { error_log("MBZ[$rid] forum NEWS detected -> mapping to announcements"); } |
||
| 201 | $anns = $this->readAnnouncementsFromForum($moduleXml, $workDir); |
||
| 202 | |||
| 203 | if (empty($anns)) { |
||
| 204 | if ($this->debug) { error_log("MBZ[$rid] forum NEWS no-discussions fallback=module intro"); } |
||
| 205 | $f = $this->readForumModule($moduleXml); |
||
| 206 | $fallbackTitle = (string)($f['name'] ?? 'announcement'); |
||
| 207 | $fallbackHtml = (string)($f['description'] ?? ''); |
||
| 208 | $fallbackTime = (int)($f['timemodified'] ?? $f['timecreated'] ?? time()); |
||
| 209 | if ($fallbackHtml !== '') { |
||
| 210 | $anns[] = [ |
||
| 211 | 'title' => $fallbackTitle, |
||
| 212 | 'html' => $this->wrapHtmlIfNeeded($fallbackHtml, $fallbackTitle), |
||
| 213 | 'date' => date('Y-m-d H:i:s', $fallbackTime), |
||
| 214 | 'attachments' => [], |
||
| 215 | ]; |
||
| 216 | } |
||
| 217 | } |
||
| 218 | |||
| 219 | foreach ($anns as $a) { |
||
| 220 | $iid = $this->nextId($resources['announcement']); |
||
| 221 | $payload = [ |
||
| 222 | 'title' => (string) $a['title'], |
||
| 223 | 'content' => (string) $this->wrapHtmlIfNeeded($a['html'], (string)$a['title']), |
||
| 224 | 'date' => (string) $a['date'], |
||
| 225 | 'display_order' => 0, |
||
| 226 | 'email_sent' => 0, |
||
| 227 | 'attachment_path' => (string) ($a['first_path'] ?? ''), |
||
| 228 | 'attachment_filename' => (string) ($a['first_name'] ?? ''), |
||
| 229 | 'attachment_size' => (int) ($a['first_size'] ?? 0), |
||
| 230 | 'attachment_comment' => '', |
||
| 231 | 'attachments' => (array) ($a['attachments'] ?? []), |
||
| 232 | ]; |
||
| 233 | $resources['announcement'][$iid] = $this->mkLegacyItem('announcement', $iid, $payload, ['attachments']); |
||
| 234 | } |
||
| 235 | if ($this->debug) { error_log("MBZ[$rid] forum NEWS mapped announcements.count=".count($anns)." -> skip forum case"); } |
||
| 236 | continue; // Skip normal forum case |
||
| 237 | } |
||
| 238 | } |
||
| 239 | } |
||
| 240 | |||
| 241 | switch ($modName) { |
||
| 242 | case 'label': { |
||
| 243 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 244 | $data = $this->readHtmlModule($moduleXml, 'label'); |
||
| 245 | $title = (string) ($data['name'] ?? 'Label'); |
||
| 246 | $html = (string) $this->wrapHtmlIfNeeded( |
||
| 247 | $this->rewritePluginfileBasic((string) ($data['content'] ?? ''), 'label'), |
||
| 248 | $title |
||
| 249 | ); |
||
| 250 | $descId = $this->nextId($resources['course_descriptions']); |
||
| 251 | $resources['course_descriptions'][$descId] = $this->mkLegacyItem('course_descriptions', $descId, [ |
||
| 252 | 'title' => $title, |
||
| 253 | 'content' => $html, |
||
| 254 | 'description_type' => 0, |
||
| 255 | 'source_id' => $descId, |
||
| 256 | ]); |
||
| 257 | if ($this->debug) { error_log("MBZ[$rid] label -> course_descriptions id={$descId} title=".json_encode($title)); } |
||
| 258 | break; |
||
| 259 | } |
||
| 260 | |||
| 261 | case 'page': { |
||
| 262 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 263 | $isHomepage = $this->looksLikeCourseHomepage($dir, $moduleXml); |
||
| 264 | |||
| 265 | if ($isHomepage) { |
||
| 266 | $raw = $this->readPageContent($moduleXml); |
||
| 267 | $html = (string) $this->wrapHtmlIfNeeded( |
||
| 268 | $this->rewritePluginfileBasic($raw, 'page'), |
||
| 269 | get_lang('Introduction') |
||
| 270 | ); |
||
| 271 | if (!isset($resources['tool_intro']['course_homepage'])) { |
||
| 272 | $resources['tool_intro']['course_homepage'] = $this->mkLegacyItem('tool_intro', 0, [ |
||
| 273 | 'id' => 'course_homepage', |
||
| 274 | 'intro_text' => $html, |
||
| 275 | ]); |
||
| 276 | if ($this->debug) { error_log("MBZ[$rid] page HOMEPAGE -> tool_intro[course_homepage] set"); } |
||
| 277 | } else { |
||
| 278 | if ($this->debug) { error_log("MBZ[$rid] page HOMEPAGE -> tool_intro[course_homepage] exists, skip overwrite"); } |
||
| 279 | } |
||
| 280 | break; |
||
| 281 | } |
||
| 282 | |||
| 283 | $data = $this->readHtmlModule($moduleXml, $modName); |
||
| 284 | $docId = $this->nextId($resources['document']); |
||
| 285 | $slug = $data['slug'] ?: ('page_'.$docId); |
||
| 286 | $rel = 'document/moodle_pages/'.$slug.'.html'; |
||
| 287 | $abs = $workDir.'/'.$rel; |
||
| 288 | |||
| 289 | $this->ensureDir(\dirname($abs)); |
||
| 290 | $html = $this->wrapHtmlIfNeeded($data['content'] ?? '', $data['name'] ?? ucfirst($modName)); |
||
| 291 | file_put_contents($abs, $html); |
||
| 292 | |||
| 293 | $resources['document'][$docId] = $this->mkLegacyItem('document', $docId, [ |
||
| 294 | 'file_type' => 'file', |
||
| 295 | 'path' => '/'.$rel, |
||
| 296 | 'title' => (string) ($data['name'] ?? ucfirst($modName)), |
||
| 297 | 'size' => @filesize($abs) ?: 0, |
||
| 298 | 'comment' => '', |
||
| 299 | ]); |
||
| 300 | if ($this->debug) { error_log("MBZ[$rid] page -> document id={$docId} path=/{$rel} title=".json_encode($resources['document'][$docId]->title)); } |
||
| 301 | |||
| 302 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 303 | $lpMap[$sectionId]['items'][] = [ |
||
| 304 | 'item_type' => 'document', |
||
| 305 | 'ref' => $docId, |
||
| 306 | 'title' => $data['name'] ?? ucfirst($modName), |
||
| 307 | ]; |
||
| 308 | if ($this->debug) { error_log("MBZ[$rid] page -> LP section={$sectionId} add document ref={$docId}"); } |
||
| 309 | } |
||
| 310 | break; |
||
| 311 | } |
||
| 312 | |||
| 313 | case 'forum': { |
||
| 314 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 315 | |||
| 316 | // If there is Chamilo meta for announcements, prefer it and let non-news forums pass through |
||
| 317 | if ($this->hasChamiloAnnouncementMeta($workDir)) { |
||
| 318 | if ($this->debug) { error_log('MOODLE_IMPORT: announcements meta present → keep forum import for non-news'); } |
||
| 319 | } |
||
| 320 | |||
| 321 | // 1) Read forum type header |
||
| 322 | $forumInfo = $this->readForumHeader($moduleXml); // ['type' => 'news'|'general'|..., 'name' => ..., ...] |
||
| 323 | if ($this->debug) { |
||
| 324 | error_log('MOODLE_IMPORT: forum header peek -> ' . json_encode([ |
||
| 325 | 'name' => $forumInfo['name'] ?? null, |
||
| 326 | 'type' => $forumInfo['type'] ?? null, |
||
| 327 | ])); |
||
| 328 | } |
||
| 329 | |||
| 330 | // 2) If it's a "news" forum and meta wasn't used → import as announcements (with fallback) |
||
| 331 | if (!$this->hasChamiloAnnouncementMeta($workDir) && $this->isNewsForum($forumInfo)) { |
||
| 332 | $anns = $this->readAnnouncementsFromForum($moduleXml, $workDir); |
||
| 333 | if ($this->debug) { |
||
| 334 | error_log('MOODLE_IMPORT: news-forum detected, announcements extracted=' . count($anns)); |
||
| 335 | } |
||
| 336 | |||
| 337 | if (empty($anns)) { |
||
| 338 | if ($this->debug) { error_log('MOODLE_IMPORT: announcements empty -> intro fallback (in switch)'); } |
||
| 339 | $f = $this->readForumModule($moduleXml); |
||
| 340 | $fallbackTitle = (string)($f['name'] ?? 'announcement'); |
||
| 341 | $fallbackHtml = (string)($f['description'] ?? ''); |
||
| 342 | $fallbackTime = (int)($f['timemodified'] ?? $f['timecreated'] ?? time()); |
||
| 343 | if ($fallbackHtml !== '') { |
||
| 344 | $anns[] = [ |
||
| 345 | 'title' => $fallbackTitle, |
||
| 346 | 'html' => $this->wrapHtmlIfNeeded($fallbackHtml, $fallbackTitle), |
||
| 347 | 'date' => date('Y-m-d H:i:s', $fallbackTime), |
||
| 348 | 'attachments' => [], |
||
| 349 | ]; |
||
| 350 | } |
||
| 351 | } |
||
| 352 | |||
| 353 | foreach ($anns as $a) { |
||
| 354 | $iid = $this->nextId($resources['announcement']); |
||
| 355 | $payload = [ |
||
| 356 | 'title' => (string) $a['title'], |
||
| 357 | 'content' => (string) $this->wrapHtmlIfNeeded($a['html'], (string)$a['title']), |
||
| 358 | 'date' => (string) $a['date'], |
||
| 359 | 'display_order' => 0, |
||
| 360 | 'email_sent' => 0, |
||
| 361 | 'attachment_path' => (string) ($a['first_path'] ?? ''), |
||
| 362 | 'attachment_filename' => (string) ($a['first_name'] ?? ''), |
||
| 363 | 'attachment_size' => (int) ($a['first_size'] ?? 0), |
||
| 364 | 'attachment_comment' => '', |
||
| 365 | 'attachments' => (array) ($a['attachments'] ?? []), |
||
| 366 | ]; |
||
| 367 | $resources['announcement'][$iid] = $this->mkLegacyItem('announcement', $iid, $payload, ['attachments']); |
||
| 368 | } |
||
| 369 | |||
| 370 | // Do NOT also import as forum |
||
| 371 | break; |
||
| 372 | } |
||
| 373 | |||
| 374 | // 3) Normal forum path (general, Q&A, etc.) |
||
| 375 | $f = $this->readForumModule($moduleXml); |
||
| 376 | |||
| 377 | $catId = (int) ($f['category_id'] ?? 0); |
||
| 378 | $catTitle = (string) ($f['category_title'] ?? ''); |
||
| 379 | if ($catId > 0 && !isset($resources['Forum_Category'][$catId])) { |
||
| 380 | $resources['Forum_Category'][$catId] = $this->mkLegacyItem('Forum_Category', $catId, [ |
||
| 381 | 'id' => $catId, |
||
| 382 | 'cat_title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), |
||
| 383 | 'cat_comment' => '', |
||
| 384 | 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), |
||
| 385 | 'description' => '', |
||
| 386 | ]); |
||
| 387 | if ($this->debug) { error_log("MBZ[$rid] forum -> created Forum_Category id={$catId} title=".json_encode($catTitle)); } |
||
| 388 | } |
||
| 389 | $dstCatId = $catId > 0 ? $catId : $defaultForumCatId; |
||
| 390 | |||
| 391 | $fid = $this->nextId($resources['forum']); |
||
| 392 | $resources['forum'][$fid] = $this->mkLegacyItem('forum', $fid, [ |
||
| 393 | 'id' => $fid, |
||
| 394 | 'forum_title' => (string) ($f['name'] ?? 'Forum'), |
||
| 395 | 'forum_comment' => (string) ($f['description'] ?? ''), |
||
| 396 | 'forum_category' => $dstCatId, |
||
| 397 | 'default_view' => 'flat', |
||
| 398 | ]); |
||
| 399 | if ($this->debug) { error_log("MBZ[$rid] forum -> forum id={$fid} category={$dstCatId} title=".json_encode($resources['forum'][$fid]->forum_title)); } |
||
| 400 | |||
| 401 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 402 | $lpMap[$sectionId]['items'][] = [ |
||
| 403 | 'item_type' => 'forum', |
||
| 404 | 'ref' => $fid, |
||
| 405 | 'title' => $f['name'] ?? 'Forum', |
||
| 406 | ]; |
||
| 407 | if ($this->debug) { error_log("MBZ[$rid] forum -> LP section={$sectionId} add forum ref={$fid}"); } |
||
| 408 | } |
||
| 409 | break; |
||
| 410 | } |
||
| 411 | |||
| 412 | case 'url': { |
||
| 413 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 414 | $u = $this->readUrlModule($moduleXml); |
||
| 415 | $urlVal = trim((string) ($u['url'] ?? '')); |
||
| 416 | if ('' === $urlVal) { if ($this->debug) { error_log("MBZ[$rid] url -> empty url, skip"); } break; } |
||
| 417 | |||
| 418 | $catId = (int) ($u['category_id'] ?? 0); |
||
| 419 | $catTitle = (string) ($u['category_title'] ?? ''); |
||
| 420 | if ($catId > 0 && !isset($resources['Link_Category'][$catId])) { |
||
| 421 | $resources['Link_Category'][$catId] = $this->mkLegacyItem('Link_Category', $catId, [ |
||
| 422 | 'id' => $catId, |
||
| 423 | 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), |
||
| 424 | 'description' => '', |
||
| 425 | ]); |
||
| 426 | if ($this->debug) { error_log("MBZ[$rid] url -> created Link_Category id={$catId} title=".json_encode($catTitle)); } |
||
| 427 | } |
||
| 428 | |||
| 429 | $lid = $this->nextId($resources['link']); |
||
| 430 | $linkTitle = ($u['name'] ?? '') !== '' ? (string) $u['name'] : $urlVal; |
||
| 431 | |||
| 432 | $resources['link'][$lid] = $this->mkLegacyItem('link', $lid, [ |
||
| 433 | 'id' => $lid, |
||
| 434 | 'title' => $linkTitle, |
||
| 435 | 'description' => '', |
||
| 436 | 'url' => $urlVal, |
||
| 437 | 'target' => '', |
||
| 438 | 'category_id' => $catId, |
||
| 439 | 'on_homepage' => false, |
||
| 440 | ]); |
||
| 441 | if ($this->debug) { error_log("MBZ[$rid] url -> link id={$lid} url=".json_encode($urlVal)); } |
||
| 442 | |||
| 443 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 444 | $lpMap[$sectionId]['items'][] = [ |
||
| 445 | 'item_type' => 'link', |
||
| 446 | 'ref' => $lid, |
||
| 447 | 'title' => $linkTitle, |
||
| 448 | ]; |
||
| 449 | if ($this->debug) { error_log("MBZ[$rid] url -> LP section={$sectionId} add link ref={$lid}"); } |
||
| 450 | } |
||
| 451 | break; |
||
| 452 | } |
||
| 453 | |||
| 454 | case 'scorm': { |
||
| 455 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 456 | $sc = $this->readScormModule($moduleXml); |
||
| 457 | $sid = $this->nextId($resources['scorm_documents']); |
||
| 458 | |||
| 459 | $resources['scorm_documents'][$sid] = $this->mkLegacyItem('scorm_documents', $sid, [ |
||
| 460 | 'id' => $sid, |
||
| 461 | 'title' => (string) ($sc['name'] ?? 'SCORM package'), |
||
| 462 | ]); |
||
| 463 | $resources['scorm'][$sid] = $resources['scorm_documents'][$sid]; |
||
| 464 | if ($this->debug) { error_log("MBZ[$rid] scorm -> scorm_documents id={$sid}"); } |
||
| 465 | |||
| 466 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 467 | $lpMap[$sectionId]['items'][] = [ |
||
| 468 | 'item_type' => 'scorm', |
||
| 469 | 'ref' => $sid, |
||
| 470 | 'title' => $sc['name'] ?? 'SCORM package', |
||
| 471 | ]; |
||
| 472 | if ($this->debug) { error_log("MBZ[$rid] scorm -> LP section={$sectionId} add scorm ref={$sid}"); } |
||
| 473 | } |
||
| 474 | break; |
||
| 475 | } |
||
| 476 | |||
| 477 | case 'quiz': { |
||
| 478 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 479 | if ($hasQuizMeta) { |
||
| 480 | $peekTitle = $this->peekQuizTitle($moduleXml); |
||
| 481 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 482 | $lpMap[$sectionId]['items'][] = [ |
||
| 483 | 'item_type' => 'quiz', |
||
| 484 | 'ref' => null, |
||
| 485 | 'title' => $peekTitle ?? 'Quiz', |
||
| 486 | ]; |
||
| 487 | if ($this->debug) { error_log("MBZ[$rid] quiz(meta) -> LP section={$sectionId} add quiz (ref=null) title=".json_encode($peekTitle ?? 'Quiz')); } |
||
| 488 | } |
||
| 489 | $skippedQuizXml++; |
||
| 490 | if ($this->debug) { error_log("MBZ[$rid] quiz(meta) skipping heavy XML (skipped={$skippedQuizXml})"); } |
||
| 491 | break; |
||
| 492 | } |
||
| 493 | |||
| 494 | [$quiz, $questions] = $this->readQuizModule($workDir, $dir, $moduleXml); |
||
| 495 | if (!empty($quiz)) { |
||
| 496 | $qid = $this->nextId($resources['quizzes']); |
||
| 497 | $resources['quizzes'][$qid] = $this->mkLegacyItem('quizzes', $qid, $quiz); |
||
| 498 | if ($this->debug) { error_log("MBZ[$rid] quiz -> quizzes id={$qid} title=".json_encode($quiz['name'] ?? 'Quiz')); } |
||
| 499 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 500 | $lpMap[$sectionId]['items'][] = [ |
||
| 501 | 'item_type' => 'quiz', |
||
| 502 | 'ref' => $qid, |
||
| 503 | 'title' => $quiz['name'] ?? 'Quiz', |
||
| 504 | ]; |
||
| 505 | if ($this->debug) { error_log("MBZ[$rid] quiz -> LP section={$sectionId} add quiz ref={$qid}"); } |
||
| 506 | } |
||
| 507 | foreach ($questions as $q) { |
||
| 508 | $qqid = $this->nextId($resources['quiz_question']); |
||
| 509 | $resources['quiz_question'][$qqid] = $this->mkLegacyItem('quiz_question', $qqid, $q); |
||
| 510 | } |
||
| 511 | if ($this->debug) { error_log("MBZ[$rid] quiz -> quiz_question added=".count($questions)); } |
||
| 512 | } |
||
| 513 | break; |
||
| 514 | } |
||
| 515 | |||
| 516 | case 'survey': |
||
| 517 | case 'feedback': { |
||
| 518 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 519 | $s = $this->readSurveyModule($moduleXml, $modName); |
||
| 520 | if (!empty($s)) { |
||
| 521 | $sid = $this->nextId($resources['surveys']); |
||
| 522 | $resources['surveys'][$sid] = $this->mkLegacyItem('surveys', $sid, $s); |
||
| 523 | if ($this->debug) { error_log("MBZ[$rid] {$modName} -> surveys id={$sid}"); } |
||
| 524 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 525 | $lpMap[$sectionId]['items'][] = [ |
||
| 526 | 'item_type' => 'survey', |
||
| 527 | 'ref' => $sid, |
||
| 528 | 'title' => $s['name'] ?? ucfirst($modName), |
||
| 529 | ]; |
||
| 530 | if ($this->debug) { error_log("MBZ[$rid] {$modName} -> LP section={$sectionId} add survey ref={$sid}"); } |
||
| 531 | } |
||
| 532 | } |
||
| 533 | break; |
||
| 534 | } |
||
| 535 | |||
| 536 | case 'assign': { |
||
| 537 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 538 | $w = $this->readAssignModule($moduleXml); |
||
| 539 | if (!empty($w)) { |
||
| 540 | $wid = $this->nextId($resources['works']); |
||
| 541 | $resources['works'][$wid] = $this->mkLegacyItem('works', $wid, $w); |
||
| 542 | if ($this->debug) { error_log("MBZ[$rid] assign -> works id={$wid}"); } |
||
| 543 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 544 | $lpMap[$sectionId]['items'][] = [ |
||
| 545 | 'item_type' => 'works', |
||
| 546 | 'ref' => $wid, |
||
| 547 | 'title' => $w['name'] ?? 'Assignment', |
||
| 548 | ]; |
||
| 549 | if ($this->debug) { error_log("MBZ[$rid] assign -> LP section={$sectionId} add works ref={$wid}"); } |
||
| 550 | } |
||
| 551 | } |
||
| 552 | break; |
||
| 553 | } |
||
| 554 | |||
| 555 | case 'glossary': { |
||
| 556 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 557 | $g = $this->readGlossaryModule($moduleXml); |
||
| 558 | $added = 0; |
||
| 559 | foreach ((array) ($g['entries'] ?? []) as $term) { |
||
| 560 | $title = (string) ($term['concept'] ?? ''); |
||
| 561 | if ($title === '') { continue; } |
||
| 562 | $descHtml = $this->wrapHtmlIfNeeded((string) ($term['definition'] ?? ''), $title); |
||
| 563 | $gid = $this->nextId($resources['glossary']); |
||
| 564 | $resources['glossary'][$gid] = $this->mkLegacyItem('glossary', $gid, [ |
||
| 565 | 'id' => $gid, |
||
| 566 | 'title' => $title, |
||
| 567 | 'description' => $descHtml, |
||
| 568 | 'approved' => (int) ($term['approved'] ?? 1), |
||
| 569 | 'aliases' => (array) ($term['aliases'] ?? []), |
||
| 570 | 'userid' => (int) ($term['userid'] ?? 0), |
||
| 571 | 'timecreated' => (int) ($term['timecreated'] ?? 0), |
||
| 572 | 'timemodified'=> (int) ($term['timemodified'] ?? 0), |
||
| 573 | ]); |
||
| 574 | $added++; |
||
| 575 | } |
||
| 576 | if ($this->debug) { error_log("MBZ[$rid] glossary -> entries added={$added}"); } |
||
| 577 | break; |
||
| 578 | } |
||
| 579 | |||
| 580 | case 'wiki': { |
||
| 581 | if (!$moduleXml || !is_file($moduleXml)) { break; } |
||
| 582 | [$meta, $pages] = $this->readWikiModuleFull($moduleXml); |
||
| 583 | $added = 0; |
||
| 584 | if (!empty($pages)) { |
||
| 585 | foreach ($pages as $p) { |
||
| 586 | $payload = [ |
||
| 587 | 'pageId' => (int) $p['id'], |
||
| 588 | 'reflink' => (string) ($p['reflink'] ?? $this->slugify((string)$p['title'])), |
||
| 589 | 'title' => (string) $p['title'], |
||
| 590 | 'content' => (string) $this->wrapHtmlIfNeeded($this->rewritePluginfileBasic((string)($p['content'] ?? ''), 'wiki'), (string)$p['title']), |
||
| 591 | 'userId' => (int) ($p['userid'] ?? 0), |
||
| 592 | 'groupId' => 0, |
||
| 593 | 'dtime' => date('Y-m-d H:i:s', (int) ($p['timemodified'] ?? time())), |
||
| 594 | 'progress'=> '', |
||
| 595 | 'version' => (int) ($p['version'] ?? 1), |
||
| 596 | 'source_id' => (int) $p['id'], |
||
| 597 | 'source_moduleid' => (int) ($meta['moduleid'] ?? 0), |
||
| 598 | 'source_sectionid'=> (int) ($meta['sectionid'] ?? 0), |
||
| 599 | ]; |
||
| 600 | $wkid = $this->nextId($resources['wiki']); |
||
| 601 | $resources['wiki'][$wkid] = $this->mkLegacyItem('wiki', $wkid, $payload); |
||
| 602 | $added++; |
||
| 603 | } |
||
| 604 | } |
||
| 605 | if ($this->debug) { error_log("MBZ[$rid] wiki -> pages added={$added}"); } |
||
| 606 | break; |
||
| 607 | } |
||
| 608 | |||
| 609 | default: |
||
| 610 | if ($this->debug) { error_log("MBZ[$rid] unhandled module {$modName}"); } |
||
| 611 | break; |
||
| 612 | } |
||
| 613 | |||
| 614 | if ($this->debug && ($i % 10 === 0)) { |
||
| 615 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 616 | error_log("MBZ[$rid] progress.counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 617 | } |
||
| 618 | } |
||
| 619 | |||
| 620 | // 8) Documents from resource + folder + inlined pluginfile |
||
| 621 | $this->readDocuments($workDir, $mb, $fileIndex, $resources, $lpMap); |
||
| 622 | if ($this->debug) { |
||
| 623 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 624 | error_log("MBZ[$rid] after.readDocuments counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 625 | } |
||
| 626 | |||
| 627 | // 8.1) Import quizzes from meta when present (skipping XML ensured above) |
||
| 628 | if ($hasQuizMeta) { |
||
| 629 | $this->tryImportQuizMeta($workDir, $resources); |
||
| 630 | if ($this->debug) { |
||
| 631 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 632 | error_log("MBZ[$rid] after.quizMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 633 | } |
||
| 634 | } |
||
| 635 | |||
| 636 | // 8.2) Prefer LP meta; otherwise fallback to sections |
||
| 637 | $lpFromMeta = false; |
||
| 638 | if ($hasLpMeta) { |
||
| 639 | $lpFromMeta = $this->tryImportLearnpathMeta($workDir, $resources); |
||
| 640 | if ($this->debug) { |
||
| 641 | error_log("MBZ[$rid] lpFromMeta=".($lpFromMeta?1:0)); |
||
| 642 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 643 | error_log("MBZ[$rid] after.lpMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 644 | } |
||
| 645 | } |
||
| 646 | |||
| 647 | // 8.3) Thematic meta (authoritative) |
||
| 648 | $this->tryImportThematicMeta($workDir, $resources); |
||
| 649 | if ($this->debug) { |
||
| 650 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 651 | error_log("MBZ[$rid] after.thematicMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 652 | } |
||
| 653 | |||
| 654 | // 8.4) Attendance meta (authoritative) |
||
| 655 | $this->tryImportAttendanceMeta($workDir, $resources); |
||
| 656 | if ($this->debug) { |
||
| 657 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 658 | error_log("MBZ[$rid] after.attendanceMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 659 | } |
||
| 660 | |||
| 661 | // 8.5) Gradebook meta (authoritative) |
||
| 662 | $this->tryImportGradebookMeta($workDir, $resources); |
||
| 663 | if ($this->debug) { |
||
| 664 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 665 | error_log("MBZ[$rid] after.gradebookMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 666 | } |
||
| 667 | |||
| 668 | // 9) Build learnpaths from sections (fallback only if no meta) |
||
| 669 | if (!$lpFromMeta && !empty($lpMap)) { |
||
| 670 | $this->backfillLpRefsFromResources($lpMap, $resources, [ |
||
| 671 | \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz', |
||
| 672 | 'quizzes', |
||
| 673 | 'document', |
||
| 674 | 'forum', |
||
| 675 | 'link', |
||
| 676 | 'scorm', |
||
| 677 | ]); |
||
| 678 | |||
| 679 | foreach ($lpMap as $sid => $lp) { |
||
| 680 | if (empty($resources['learnpath_category'])) { |
||
| 681 | $catId = $this->nextId($resources['learnpath_category']); |
||
| 682 | $resources['learnpath_category'][$catId] = $this->mkLegacyItem('learnpath_category', $catId, [ |
||
| 683 | 'id' => $catId, |
||
| 684 | 'name' => 'Sections', |
||
| 685 | 'title' => 'Sections', |
||
| 686 | ]); |
||
| 687 | if ($this->debug) { error_log("MBZ[$rid] lp.category created id={$catId}"); } |
||
| 688 | } |
||
| 689 | |||
| 690 | $linked = $this->collectLinkedFromLpItems($lp['items']); |
||
| 691 | $lid = $this->nextId($resources['learnpath']); |
||
| 692 | |||
| 693 | $resources['learnpath'][$lid] = $this->mkLegacyItem( |
||
| 694 | 'learnpath', |
||
| 695 | $lid, |
||
| 696 | [ |
||
| 697 | 'id' => $lid, |
||
| 698 | 'name' => (string) $lp['title'], |
||
| 699 | 'lp_type' => 'section', |
||
| 700 | 'category_id' => array_key_first($resources['learnpath_category']), |
||
| 701 | ], |
||
| 702 | ['items','linked_resources'] |
||
| 703 | ); |
||
| 704 | $resources['learnpath'][$lid]->items = array_map( |
||
| 705 | static fn (array $i) => [ |
||
| 706 | 'item_type' => (string) $i['item_type'], |
||
| 707 | 'title' => (string) $i['title'], |
||
| 708 | 'path' => '', |
||
| 709 | 'ref' => $i['ref'] ?? null, |
||
| 710 | ], |
||
| 711 | $lp['items'] |
||
| 712 | ); |
||
| 713 | $resources['learnpath'][$lid]->linked_resources = $linked; |
||
| 714 | |||
| 715 | if ($this->debug) { error_log("MBZ[$rid] lp.created id={$lid} name=".json_encode($resources['learnpath'][$lid]->name)); } |
||
| 716 | } |
||
| 717 | } |
||
| 718 | |||
| 719 | // 10) Course descriptions / tool intro from course meta (safe fallbacks) |
||
| 720 | if (!empty($courseMeta['summary'])) { |
||
| 721 | $cdId = $this->nextId($resources['course_descriptions']); |
||
| 722 | $resources['course_descriptions'][$cdId] = $this->mkLegacyItem('course_descriptions', $cdId, [ |
||
| 723 | 'title' => 'Course summary', |
||
| 724 | 'description' => (string) $courseMeta['summary'], |
||
| 725 | 'type' => 'summary', |
||
| 726 | ]); |
||
| 727 | $tiId = $this->nextId($resources['tool_intro']); |
||
| 728 | $resources['tool_intro'][$tiId] = $this->mkLegacyItem('tool_intro', $tiId, [ |
||
| 729 | 'tool' => 'Course home', |
||
| 730 | 'title' => 'Introduction', |
||
| 731 | 'content' => (string) $courseMeta['summary'], |
||
| 732 | ]); |
||
| 733 | if ($this->debug) { error_log("MBZ[$rid] course_meta -> added summary to course_descriptions id={$cdId} and tool_intro id={$tiId}"); } |
||
| 734 | } |
||
| 735 | |||
| 736 | // 11) Events (course-level calendar) — optional |
||
| 737 | $events = $this->readCourseEvents($workDir); |
||
| 738 | foreach ($events as $e) { |
||
| 739 | $eid = $this->nextId($resources['events']); |
||
| 740 | $resources['events'][$eid] = $this->mkLegacyItem('events', $eid, $e); |
||
| 741 | } |
||
| 742 | if ($this->debug) { error_log("MBZ[$rid] events.added count=".count($events)); } |
||
| 743 | |||
| 744 | $resources = $this->canonicalizeResourceBags($resources); |
||
| 745 | if ($this->debug) { |
||
| 746 | $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); |
||
| 747 | error_log("MBZ[$rid] final.counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); |
||
| 748 | } |
||
| 749 | |||
| 750 | // 12) Compose Course snapshot |
||
| 751 | $course = new Course(); |
||
| 752 | $course->resources = $resources; |
||
| 753 | $course->backup_path = $workDir; |
||
| 754 | |||
| 755 | // 13) Meta: "metaexport" + derived moodle meta |
||
| 756 | $meta = [ |
||
| 757 | 'import_source' => 'moodle', |
||
| 758 | 'generated_at' => date('c'), |
||
| 759 | 'moodle' => [ |
||
| 760 | 'fullname' => (string) ($courseMeta['fullname'] ?? ''), |
||
| 761 | 'shortname' => (string) ($courseMeta['shortname'] ?? ''), |
||
| 762 | 'idnumber' => (string) ($courseMeta['idnumber'] ?? ''), |
||
| 763 | 'startdate' => (int) ($courseMeta['startdate'] ?? 0), |
||
| 764 | 'enddate' => (int) ($courseMeta['enddate'] ?? 0), |
||
| 765 | 'format' => (string) ($courseMeta['format'] ?? ''), |
||
| 766 | ], |
||
| 767 | ]; |
||
| 768 | |||
| 769 | // Merge metaexport JSON if present (export_meta.json | meta_export.json) |
||
| 770 | $meta = $this->mergeMetaExportIfPresent($workDir, $meta); // NEW |
||
| 771 | |||
| 772 | $course->meta = $meta; |
||
| 773 | $course->resources['__meta'] = $meta; |
||
| 774 | |||
| 775 | // 14) Optional course basic info |
||
| 776 | $ci = \function_exists('api_get_course_info') ? (api_get_course_info() ?: []) : []; |
||
| 777 | if (property_exists($course, 'code')) { |
||
| 778 | $course->code = (string) ($ci['code'] ?? ''); |
||
| 779 | } |
||
| 780 | if (property_exists($course, 'type')) { |
||
| 781 | $course->type = 'partial'; |
||
| 782 | } |
||
| 783 | if (property_exists($course, 'encoding')) { |
||
| 784 | $course->encoding = \function_exists('api_get_system_encoding') |
||
| 785 | ? api_get_system_encoding() |
||
| 786 | : 'UTF-8'; |
||
| 787 | } |
||
| 788 | |||
| 789 | if ($this->debug) { |
||
| 790 | error_log('MOODLE_IMPORT: resources='.json_encode( |
||
| 791 | array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources), |
||
| 792 | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES |
||
| 793 | )); |
||
| 794 | error_log('MOODLE_IMPORT: backup_path='.$course->backup_path); |
||
| 795 | if (property_exists($course, 'code') && property_exists($course, 'encoding')) { |
||
| 796 | error_log('MOODLE_IMPORT: course_code='.$course->code.' encoding='.$course->encoding); |
||
| 797 | } |
||
| 798 | error_log("MBZ[$rid] DONE buildLegacyCourseFromMoodleArchive"); |
||
| 799 | } |
||
| 800 | |||
| 801 | return $course; |
||
| 802 | } |
||
| 803 | |||
| 804 | |||
| 805 | private function extractToTemp(string $archivePath): array |
||
| 806 | { |
||
| 807 | $base = rtrim(sys_get_temp_dir(), '/').'/moodle_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)); |
||
| 808 | if (!@mkdir($base, 0775, true)) { |
||
| 809 | throw new RuntimeException('Cannot create temp dir'); |
||
| 810 | } |
||
| 811 | |||
| 812 | $ext = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION)); |
||
| 813 | if (\in_array($ext, ['zip', 'mbz'], true)) { |
||
| 814 | $zip = new ZipArchive(); |
||
| 815 | if (true !== $zip->open($archivePath)) { |
||
| 816 | throw new RuntimeException('Cannot open zip'); |
||
| 817 | } |
||
| 818 | if (!$zip->extractTo($base)) { |
||
| 819 | $zip->close(); |
||
| 820 | |||
| 821 | throw new RuntimeException('Cannot extract zip'); |
||
| 822 | } |
||
| 823 | $zip->close(); |
||
| 824 | } elseif (\in_array($ext, ['gz', 'tgz'], true)) { |
||
| 825 | $phar = new PharData($archivePath); |
||
| 826 | $phar->extractTo($base, null, true); |
||
| 827 | } else { |
||
| 828 | throw new RuntimeException('Unsupported archive type'); |
||
| 829 | } |
||
| 830 | |||
| 831 | if (!is_file($base.'/moodle_backup.xml')) { |
||
| 832 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 833 | } |
||
| 834 | |||
| 835 | return [$base]; |
||
| 836 | } |
||
| 837 | |||
| 838 | private function loadXml(string $path): DOMDocument |
||
| 839 | { |
||
| 840 | $xml = @file_get_contents($path); |
||
| 841 | if (false === $xml || '' === $xml) { |
||
| 842 | throw new RuntimeException('Cannot read XML: '.$path); |
||
| 843 | } |
||
| 844 | $doc = new DOMDocument(); |
||
| 845 | $doc->preserveWhiteSpace = false; |
||
| 846 | if (!@$doc->loadXML($xml)) { |
||
| 847 | throw new RuntimeException('Invalid XML: '.$path); |
||
| 848 | } |
||
| 849 | |||
| 850 | return $doc; |
||
| 851 | } |
||
| 852 | |||
| 853 | /** |
||
| 854 | * Build an index from files.xml. |
||
| 855 | * Returns ['byId' => [id => row], 'byHash' => [hash => row]]. |
||
| 856 | * Each row contains: id, hash, filename, filepath, component, filearea, mimetype, filesize, contextid, blob(abs path). |
||
| 857 | */ |
||
| 858 | private function buildFileIndex(string $filesXmlPath, string $workDir): array |
||
| 859 | { |
||
| 860 | $doc = $this->loadXml($filesXmlPath); |
||
| 861 | $xp = new DOMXPath($doc); |
||
| 862 | |||
| 863 | $byId = []; |
||
| 864 | $byHash = []; |
||
| 865 | |||
| 866 | foreach ($xp->query('//file') as $f) { |
||
| 867 | /** @var DOMElement $f */ |
||
| 868 | $id = (int) ($f->getAttribute('id') ?? 0); |
||
| 869 | $hash = (string) ($f->getElementsByTagName('contenthash')->item(0)?->nodeValue ?? ''); |
||
| 870 | if ('' === $hash) { |
||
| 871 | continue; |
||
| 872 | } |
||
| 873 | |||
| 874 | $name = (string) ($f->getElementsByTagName('filename')->item(0)?->nodeValue ?? ''); |
||
| 875 | $fp = (string) ($f->getElementsByTagName('filepath')->item(0)?->nodeValue ?? '/'); |
||
| 876 | $comp = (string) ($f->getElementsByTagName('component')->item(0)?->nodeValue ?? ''); |
||
| 877 | $fa = (string) ($f->getElementsByTagName('filearea')->item(0)?->nodeValue ?? ''); |
||
| 878 | $mime = (string) ($f->getElementsByTagName('mimetype')->item(0)?->nodeValue ?? ''); |
||
| 879 | $size = (int) ($f->getElementsByTagName('filesize')->item(0)?->nodeValue ?? 0); |
||
| 880 | $ctx = (int) ($f->getElementsByTagName('contextid')->item(0)?->nodeValue ?? 0); |
||
| 881 | |||
| 882 | $blob = $this->contentHashPath($workDir, $hash); |
||
| 883 | |||
| 884 | $row = [ |
||
| 885 | 'id' => $id, |
||
| 886 | 'hash' => $hash, |
||
| 887 | 'filename' => $name, |
||
| 888 | 'filepath' => $fp, |
||
| 889 | 'component' => $comp, |
||
| 890 | 'filearea' => $fa, |
||
| 891 | 'mimetype' => $mime, |
||
| 892 | 'filesize' => $size, |
||
| 893 | 'contextid' => $ctx, |
||
| 894 | 'blob' => $blob, |
||
| 895 | ]; |
||
| 896 | |||
| 897 | if ($id > 0) { |
||
| 898 | $byId[$id] = $row; |
||
| 899 | } |
||
| 900 | $byHash[$hash] = $row; |
||
| 901 | } |
||
| 902 | |||
| 903 | return ['byId' => $byId, 'byHash' => $byHash]; |
||
| 904 | } |
||
| 905 | |||
| 906 | private function readSections(DOMXPath $xp): array |
||
| 907 | { |
||
| 908 | $out = []; |
||
| 909 | foreach ($xp->query('//section') as $s) { |
||
| 910 | /** @var DOMElement $s */ |
||
| 911 | $id = (int) ($s->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); |
||
| 912 | if ($id <= 0) { |
||
| 913 | $id = (int) ($s->getElementsByTagName('number')->item(0)?->nodeValue |
||
| 914 | ?? $s->getElementsByTagName('id')->item(0)?->nodeValue |
||
| 915 | ?? 0); |
||
| 916 | } |
||
| 917 | $name = (string) ($s->getElementsByTagName('name')->item(0)?->nodeValue ?? ''); |
||
| 918 | $summary = (string) ($s->getElementsByTagName('summary')->item(0)?->nodeValue ?? ''); |
||
| 919 | if ($id > 0) { |
||
| 920 | $out[$id] = ['id' => $id, 'name' => $name, 'summary' => $summary]; |
||
| 921 | } |
||
| 922 | } |
||
| 923 | |||
| 924 | return $out; |
||
| 925 | } |
||
| 926 | |||
| 927 | private function sectionsToLearnpaths(array $sections): array |
||
| 928 | { |
||
| 929 | $map = []; |
||
| 930 | foreach ($sections as $sid => $s) { |
||
| 931 | $title = $s['name'] ?: ('Section '.$sid); |
||
| 932 | $map[(int) $sid] = [ |
||
| 933 | 'title' => $title, |
||
| 934 | 'items' => [], |
||
| 935 | ]; |
||
| 936 | } |
||
| 937 | |||
| 938 | return $map; |
||
| 939 | } |
||
| 940 | |||
| 941 | private function readHtmlModule(string $xmlPath, string $type): array |
||
| 942 | { |
||
| 943 | $doc = $this->loadXml($xmlPath); |
||
| 944 | $xp = new DOMXPath($doc); |
||
| 945 | |||
| 946 | $name = (string) ($xp->query('//name')->item(0)?->nodeValue ?? ucfirst($type)); |
||
| 947 | |||
| 948 | $content = (string) ($xp->query('//intro')->item(0)?->nodeValue |
||
| 949 | ?? $xp->query('//content')->item(0)?->nodeValue |
||
| 950 | ?? ''); |
||
| 951 | |||
| 952 | // NEW: normalize @@PLUGINFILE@@ placeholders so local files resolve |
||
| 953 | $content = $this->normalizePluginfileContent($content); |
||
| 954 | |||
| 955 | return [ |
||
| 956 | 'name' => $name, |
||
| 957 | 'content' => $content, |
||
| 958 | 'slug' => $this->slugify($name), |
||
| 959 | ]; |
||
| 960 | } |
||
| 961 | |||
| 962 | private function readForumModule(string $xmlPath): array |
||
| 963 | { |
||
| 964 | $doc = $this->loadXml($xmlPath); |
||
| 965 | $xp = new DOMXPath($doc); |
||
| 966 | |||
| 967 | $name = trim((string) ($xp->query('//forum/name')->item(0)?->nodeValue ?? '')); |
||
| 968 | $description = (string) ($xp->query('//forum/intro')->item(0)?->nodeValue ?? ''); |
||
| 969 | $type = trim((string) ($xp->query('//forum/type')->item(0)?->nodeValue ?? 'general')); |
||
| 970 | |||
| 971 | $catId = 0; |
||
| 972 | $catTitle = ''; |
||
| 973 | if (preg_match('/CHAMILO2:forum_category_id:(\d+)/', $description, $m)) { |
||
| 974 | $catId = (int) $m[1]; |
||
| 975 | } |
||
| 976 | if (preg_match('/CHAMILO2:forum_category_title:([^\-]+?)\s*-->/u', $description, $m)) { |
||
| 977 | $catTitle = trim($m[1]); |
||
| 978 | } |
||
| 979 | |||
| 980 | return [ |
||
| 981 | 'name' => ('' !== $name ? $name : 'Forum'), |
||
| 982 | 'description' => $description, |
||
| 983 | 'type' => ('' !== $type ? $type : 'general'), |
||
| 984 | 'category_id' => $catId, |
||
| 985 | 'category_title' => $catTitle, |
||
| 986 | ]; |
||
| 987 | } |
||
| 988 | |||
| 989 | private function readUrlModule(string $xmlPath): array |
||
| 990 | { |
||
| 991 | $doc = $this->loadXml($xmlPath); |
||
| 992 | $xp = new DOMXPath($doc); |
||
| 993 | $name = trim($xp->query('//url/name')->item(0)?->nodeValue ?? ''); |
||
| 994 | $url = trim($xp->query('//url/externalurl')->item(0)?->nodeValue ?? ''); |
||
| 995 | $intro = (string) ($xp->query('//url/intro')->item(0)?->nodeValue ?? ''); |
||
| 996 | |||
| 997 | $catId = 0; |
||
| 998 | $catTitle = ''; |
||
| 999 | if (preg_match('/CHAMILO2:link_category_id:(\d+)/', $intro, $m)) { |
||
| 1000 | $catId = (int) $m[1]; |
||
| 1001 | } |
||
| 1002 | if (preg_match('/CHAMILO2:link_category_title:([^\-]+?)\s*-->/u', $intro, $m)) { |
||
| 1003 | $catTitle = trim($m[1]); |
||
| 1004 | } |
||
| 1005 | |||
| 1006 | return ['name' => $name, 'url' => $url, 'category_id' => $catId, 'category_title' => $catTitle]; |
||
| 1007 | } |
||
| 1008 | |||
| 1009 | private function readScormModule(string $xmlPath): array |
||
| 1010 | { |
||
| 1011 | $doc = $this->loadXml($xmlPath); |
||
| 1012 | $xp = new DOMXPath($doc); |
||
| 1013 | |||
| 1014 | return [ |
||
| 1015 | 'name' => (string) ($xp->query('//name')->item(0)?->nodeValue ?? 'SCORM'), |
||
| 1016 | ]; |
||
| 1017 | } |
||
| 1018 | |||
| 1019 | private function collectLinkedFromLpItems(array $items): array |
||
| 1020 | { |
||
| 1021 | $map = [ |
||
| 1022 | 'document' => 'document', |
||
| 1023 | 'forum' => 'forum', |
||
| 1024 | 'url' => 'link', |
||
| 1025 | 'link' => 'link', |
||
| 1026 | 'weblink' => 'link', |
||
| 1027 | 'work' => 'works', |
||
| 1028 | 'student_publication' => 'works', |
||
| 1029 | 'quiz' => 'quiz', |
||
| 1030 | 'exercise' => 'quiz', |
||
| 1031 | 'survey' => 'survey', |
||
| 1032 | 'scorm' => 'scorm', |
||
| 1033 | ]; |
||
| 1034 | |||
| 1035 | $out = []; |
||
| 1036 | foreach ($items as $i) { |
||
| 1037 | $t = (string) ($i['item_type'] ?? ''); |
||
| 1038 | $r = $i['ref'] ?? null; |
||
| 1039 | if ('' === $t || null === $r) { |
||
| 1040 | continue; |
||
| 1041 | } |
||
| 1042 | $bag = $map[$t] ?? $t; |
||
| 1043 | $out[$bag] ??= []; |
||
| 1044 | $out[$bag][] = (int) $r; |
||
| 1045 | } |
||
| 1046 | |||
| 1047 | return $out; |
||
| 1048 | } |
||
| 1049 | |||
| 1050 | private function nextId(array $bucket): int |
||
| 1051 | { |
||
| 1052 | $max = 0; |
||
| 1053 | foreach ($bucket as $k => $_) { |
||
| 1054 | $i = is_numeric($k) ? (int) $k : 0; |
||
| 1055 | if ($i > $max) { |
||
| 1056 | $max = $i; |
||
| 1057 | } |
||
| 1058 | } |
||
| 1059 | |||
| 1060 | return $max + 1; |
||
| 1061 | } |
||
| 1062 | |||
| 1063 | private function slugify(string $s): string |
||
| 1064 | { |
||
| 1065 | $t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s); |
||
| 1066 | $t = strtolower(preg_replace('/[^a-z0-9]+/', '-', $t ?: $s)); |
||
| 1067 | |||
| 1068 | return trim($t, '-') ?: 'item'; |
||
| 1069 | } |
||
| 1070 | |||
| 1071 | private function wrapHtmlIfNeeded(string $content, string $title = 'Page'): string |
||
| 1072 | { |
||
| 1073 | $trim = ltrim($content); |
||
| 1074 | $looksHtml = str_contains(strtolower(substr($trim, 0, 200)), '<html') |
||
| 1075 | || str_contains(strtolower(substr($trim, 0, 200)), '<!doctype'); |
||
| 1076 | |||
| 1077 | if ($looksHtml) { |
||
| 1078 | return $content; |
||
| 1079 | } |
||
| 1080 | |||
| 1081 | return "<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>". |
||
| 1082 | htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). |
||
| 1083 | "</title></head><body>\n".$content."\n</body></html>"; |
||
| 1084 | } |
||
| 1085 | |||
| 1086 | private function ensureDir(string $dir): void |
||
| 1087 | { |
||
| 1088 | if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { |
||
| 1089 | throw new RuntimeException('Cannot create directory: '.$dir); |
||
| 1090 | } |
||
| 1091 | } |
||
| 1092 | |||
| 1093 | /** |
||
| 1094 | * Resolve physical path for a given contenthash. |
||
| 1095 | * Our exporter writes blobs in: files/<first two letters of hash>/<hash>. |
||
| 1096 | */ |
||
| 1097 | private function contentHashPath(string $workDir, string $hash): string |
||
| 1098 | { |
||
| 1099 | $h = trim($hash); |
||
| 1100 | if ('' === $h || \strlen($h) < 2) { |
||
| 1101 | return $workDir.'/files/'.$h; |
||
| 1102 | } |
||
| 1103 | |||
| 1104 | // export convention: files/<two first letters>/<full-hash> |
||
| 1105 | return $workDir.'/files/'.substr($h, 0, 2).'/'.$h; |
||
| 1106 | } |
||
| 1107 | |||
| 1108 | /** |
||
| 1109 | * Fast-path: persist only Links (and Link Categories) from a Moodle backup |
||
| 1110 | * directly with Doctrine entities. This bypasses the generic Restorer so we |
||
| 1111 | * avoid ResourceType#tool and UserAuthSource#url cascade issues. |
||
| 1112 | * |
||
| 1113 | * @return array{categories:int,links:int} |
||
| 1114 | */ |
||
| 1115 | public function restoreLinks( |
||
| 1116 | string $archivePath, |
||
| 1117 | EntityManagerInterface $em, |
||
| 1118 | int $courseRealId, |
||
| 1119 | int $sessionId = 0, |
||
| 1120 | ?object $courseArg = null |
||
| 1121 | ): array { |
||
| 1122 | // Resolve parent entities |
||
| 1123 | /** @var CourseEntity|null $course */ |
||
| 1124 | $course = $em->getRepository(CourseEntity::class)->find($courseRealId); |
||
| 1125 | if (!$course) { |
||
| 1126 | throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')'); |
||
| 1127 | } |
||
| 1128 | |||
| 1129 | /** @var SessionEntity|null $session */ |
||
| 1130 | $session = $sessionId > 0 |
||
| 1131 | ? $em->getRepository(SessionEntity::class)->find($sessionId) |
||
| 1132 | : null; |
||
| 1133 | |||
| 1134 | // Fast-path: use filtered snapshot if provided (import/resources selection) |
||
| 1135 | if ($courseArg && isset($courseArg->resources) && \is_array($courseArg->resources)) { |
||
| 1136 | $linksBucket = (array) ($courseArg->resources['link'] ?? []); |
||
| 1137 | $catsBucket = (array) ($courseArg->resources['Link_Category'] ?? []); |
||
| 1138 | |||
| 1139 | if (empty($linksBucket)) { |
||
| 1140 | if ($this->debug) { |
||
| 1141 | error_log('MOODLE_IMPORT[restoreLinks]: snapshot has no selected links'); |
||
| 1142 | } |
||
| 1143 | |||
| 1144 | return ['categories' => 0, 'links' => 0]; |
||
| 1145 | } |
||
| 1146 | |||
| 1147 | // Build set of category ids actually referenced by selected links |
||
| 1148 | $usedCatIds = []; |
||
| 1149 | foreach ($linksBucket as $L) { |
||
| 1150 | $oldCatId = (int) ($L->category_id ?? 0); |
||
| 1151 | if ($oldCatId > 0) { |
||
| 1152 | $usedCatIds[$oldCatId] = true; |
||
| 1153 | } |
||
| 1154 | } |
||
| 1155 | |||
| 1156 | // Persist only needed categories |
||
| 1157 | $catMapByOldId = []; |
||
| 1158 | $newCats = 0; |
||
| 1159 | |||
| 1160 | foreach ($catsBucket as $oldId => $C) { |
||
| 1161 | if (!isset($usedCatIds[$oldId])) { |
||
| 1162 | continue; |
||
| 1163 | } |
||
| 1164 | |||
| 1165 | $cat = (new CLinkCategory()) |
||
| 1166 | ->setTitle((string) ($C->title ?? ('Category '.$oldId))) |
||
| 1167 | ->setDescription((string) ($C->description ?? '')) |
||
| 1168 | ; |
||
| 1169 | |||
| 1170 | // Parent & course/session links BEFORE persist (prePersist needs a parent) |
||
| 1171 | if (method_exists($cat, 'setParent')) { |
||
| 1172 | $cat->setParent($course); |
||
| 1173 | } elseif (method_exists($cat, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 1174 | $cat->setParentResourceNode($course->getResourceNode()); |
||
| 1175 | } |
||
| 1176 | if (method_exists($cat, 'addCourseLink')) { |
||
| 1177 | $cat->addCourseLink($course, $session); |
||
| 1178 | } |
||
| 1179 | |||
| 1180 | $em->persist($cat); |
||
| 1181 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 1182 | $newCats++; |
||
| 1183 | } |
||
| 1184 | if ($newCats > 0) { |
||
| 1185 | $em->flush(); |
||
| 1186 | } |
||
| 1187 | |||
| 1188 | // Persist selected links |
||
| 1189 | $newLinks = 0; |
||
| 1190 | foreach ($linksBucket as $L) { |
||
| 1191 | $url = trim((string) ($L->url ?? '')); |
||
| 1192 | if ('' === $url) { |
||
| 1193 | continue; |
||
| 1194 | } |
||
| 1195 | |||
| 1196 | $title = (string) ($L->title ?? ''); |
||
| 1197 | if ('' === $title) { |
||
| 1198 | $title = $url; |
||
| 1199 | } |
||
| 1200 | |||
| 1201 | $link = (new CLink()) |
||
| 1202 | ->setUrl($url) |
||
| 1203 | ->setTitle($title) |
||
| 1204 | ->setDescription((string) ($L->description ?? '')) |
||
| 1205 | ->setTarget((string) ($L->target ?? '')) |
||
| 1206 | ; |
||
| 1207 | |||
| 1208 | if (method_exists($link, 'setParent')) { |
||
| 1209 | $link->setParent($course); |
||
| 1210 | } elseif (method_exists($link, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 1211 | $link->setParentResourceNode($course->getResourceNode()); |
||
| 1212 | } |
||
| 1213 | if (method_exists($link, 'addCourseLink')) { |
||
| 1214 | $link->addCourseLink($course, $session); |
||
| 1215 | } |
||
| 1216 | |||
| 1217 | $oldCatId = (int) ($L->category_id ?? 0); |
||
| 1218 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 1219 | $link->setCategory($catMapByOldId[$oldCatId]); |
||
| 1220 | } |
||
| 1221 | |||
| 1222 | $em->persist($link); |
||
| 1223 | $newLinks++; |
||
| 1224 | } |
||
| 1225 | |||
| 1226 | $em->flush(); |
||
| 1227 | |||
| 1228 | if ($this->debug) { |
||
| 1229 | error_log('MOODLE_IMPORT[restoreLinks]: persisted (snapshot)='. |
||
| 1230 | json_encode(['cats' => $newCats, 'links' => $newLinks], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
||
| 1231 | } |
||
| 1232 | |||
| 1233 | return ['categories' => $newCats, 'links' => $newLinks]; |
||
| 1234 | } |
||
| 1235 | |||
| 1236 | // Extract & open main XML |
||
| 1237 | [$workDir] = $this->extractToTemp($archivePath); |
||
| 1238 | |||
| 1239 | $mbx = $workDir.'/moodle_backup.xml'; |
||
| 1240 | if (!is_file($mbx)) { |
||
| 1241 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 1242 | } |
||
| 1243 | $mbDoc = $this->loadXml($mbx); |
||
| 1244 | $mb = new DOMXPath($mbDoc); |
||
| 1245 | |||
| 1246 | // Collect URL activities -> { name, url, category hints } |
||
| 1247 | $links = []; |
||
| 1248 | $categories = []; // oldCatId => ['title' => ...] |
||
| 1249 | foreach ($mb->query('//activity') as $node) { |
||
| 1250 | /** @var DOMElement $node */ |
||
| 1251 | $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); |
||
| 1252 | if ('url' !== $modName) { |
||
| 1253 | continue; |
||
| 1254 | } |
||
| 1255 | |||
| 1256 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 1257 | $moduleXml = ('' !== $dir) ? $workDir.'/'.$dir.'/url.xml' : null; |
||
| 1258 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 1259 | if ($this->debug) { |
||
| 1260 | error_log('MOODLE_IMPORT[restoreLinks]: skip url (url.xml not found)'); |
||
| 1261 | } |
||
| 1262 | |||
| 1263 | continue; |
||
| 1264 | } |
||
| 1265 | |||
| 1266 | $u = $this->readUrlModule($moduleXml); |
||
| 1267 | |||
| 1268 | $urlVal = trim((string) ($u['url'] ?? '')); |
||
| 1269 | if ('' === $urlVal) { |
||
| 1270 | if ($this->debug) { |
||
| 1271 | error_log('MOODLE_IMPORT[restoreLinks]: skip url (empty externalurl)'); |
||
| 1272 | } |
||
| 1273 | |||
| 1274 | continue; |
||
| 1275 | } |
||
| 1276 | |||
| 1277 | $oldCatId = (int) ($u['category_id'] ?? 0); |
||
| 1278 | $oldCatTitle = (string) ($u['category_title'] ?? ''); |
||
| 1279 | if ($oldCatId > 0 && !isset($categories[$oldCatId])) { |
||
| 1280 | $categories[$oldCatId] = [ |
||
| 1281 | 'title' => ('' !== $oldCatTitle ? $oldCatTitle : ('Category '.$oldCatId)), |
||
| 1282 | 'description' => '', |
||
| 1283 | ]; |
||
| 1284 | } |
||
| 1285 | |||
| 1286 | $links[] = [ |
||
| 1287 | 'name' => (string) ($u['name'] ?? ''), |
||
| 1288 | 'url' => $urlVal, |
||
| 1289 | 'description' => '', |
||
| 1290 | 'target' => '', |
||
| 1291 | 'old_cat_id' => $oldCatId, |
||
| 1292 | ]; |
||
| 1293 | } |
||
| 1294 | |||
| 1295 | if ($this->debug) { |
||
| 1296 | error_log('MOODLE_IMPORT[restoreLinks]: to_persist='. |
||
| 1297 | json_encode(['cats' => \count($categories), 'links' => \count($links)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
||
| 1298 | } |
||
| 1299 | |||
| 1300 | if (empty($links) && empty($categories)) { |
||
| 1301 | return ['categories' => 0, 'links' => 0]; |
||
| 1302 | } |
||
| 1303 | |||
| 1304 | // Helper: robustly resolve an IID as int after flush |
||
| 1305 | $resolveIid = static function ($entity): int { |
||
| 1306 | // try entity->getIid() |
||
| 1307 | if (method_exists($entity, 'getIid')) { |
||
| 1308 | $iid = $entity->getIid(); |
||
| 1309 | if (\is_int($iid)) { |
||
| 1310 | return $iid; |
||
| 1311 | } |
||
| 1312 | if (is_numeric($iid)) { |
||
| 1313 | return (int) $iid; |
||
| 1314 | } |
||
| 1315 | } |
||
| 1316 | // fallback: resource node iid |
||
| 1317 | if (method_exists($entity, 'getResourceNode')) { |
||
| 1318 | $node = $entity->getResourceNode(); |
||
| 1319 | if ($node && method_exists($node, 'getIid')) { |
||
| 1320 | $nid = $node->getIid(); |
||
| 1321 | if (\is_int($nid)) { |
||
| 1322 | return $nid; |
||
| 1323 | } |
||
| 1324 | if (is_numeric($nid)) { |
||
| 1325 | return (int) $nid; |
||
| 1326 | } |
||
| 1327 | } |
||
| 1328 | } |
||
| 1329 | // last resort: primary ID |
||
| 1330 | if (method_exists($entity, 'getId')) { |
||
| 1331 | $id = $entity->getId(); |
||
| 1332 | if (\is_int($id)) { |
||
| 1333 | return $id; |
||
| 1334 | } |
||
| 1335 | if (is_numeric($id)) { |
||
| 1336 | return (int) $id; |
||
| 1337 | } |
||
| 1338 | } |
||
| 1339 | |||
| 1340 | return 0; |
||
| 1341 | }; |
||
| 1342 | |||
| 1343 | // Persist categories first -> flush -> refresh -> map iid |
||
| 1344 | $catMapByOldId = []; // oldCatId => CLinkCategory entity |
||
| 1345 | $iidMapByOldId = []; // oldCatId => int iid |
||
| 1346 | $newCats = 0; |
||
| 1347 | |||
| 1348 | foreach ($categories as $oldId => $payload) { |
||
| 1349 | $cat = (new CLinkCategory()) |
||
| 1350 | ->setTitle((string) $payload['title']) |
||
| 1351 | ->setDescription((string) $payload['description']) |
||
| 1352 | ; |
||
| 1353 | |||
| 1354 | // Parent & course/session links BEFORE persist (prePersist needs a parent) |
||
| 1355 | if (method_exists($cat, 'setParent')) { |
||
| 1356 | $cat->setParent($course); |
||
| 1357 | } elseif (method_exists($cat, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 1358 | $cat->setParentResourceNode($course->getResourceNode()); |
||
| 1359 | } |
||
| 1360 | if (method_exists($cat, 'addCourseLink')) { |
||
| 1361 | $cat->addCourseLink($course, $session); |
||
| 1362 | } |
||
| 1363 | |||
| 1364 | $em->persist($cat); |
||
| 1365 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 1366 | $newCats++; |
||
| 1367 | } |
||
| 1368 | |||
| 1369 | // Flush categories to get identifiers assigned |
||
| 1370 | if ($newCats > 0) { |
||
| 1371 | $em->flush(); |
||
| 1372 | // Refresh & resolve iid |
||
| 1373 | foreach ($catMapByOldId as $oldId => $cat) { |
||
| 1374 | $em->refresh($cat); |
||
| 1375 | $iidMapByOldId[$oldId] = $resolveIid($cat); |
||
| 1376 | if ($this->debug) { |
||
| 1377 | error_log('MOODLE_IMPORT[restoreLinks]: category persisted {old='.$oldId.', iid='.$iidMapByOldId[$oldId].', title='.$cat->getTitle().'}'); |
||
| 1378 | } |
||
| 1379 | } |
||
| 1380 | } |
||
| 1381 | |||
| 1382 | // Persist links (single flush at the end) |
||
| 1383 | $newLinks = 0; |
||
| 1384 | foreach ($links as $L) { |
||
| 1385 | $url = trim((string) $L['url']); |
||
| 1386 | if ('' === $url) { |
||
| 1387 | continue; |
||
| 1388 | } |
||
| 1389 | |||
| 1390 | $title = (string) ($L['name'] ?? ''); |
||
| 1391 | if ('' === $title) { |
||
| 1392 | $title = $url; |
||
| 1393 | } |
||
| 1394 | |||
| 1395 | $link = (new CLink()) |
||
| 1396 | ->setUrl($url) |
||
| 1397 | ->setTitle($title) |
||
| 1398 | ->setDescription((string) ($L['description'] ?? '')) |
||
| 1399 | ->setTarget((string) ($L['target'] ?? '')) |
||
| 1400 | ; |
||
| 1401 | |||
| 1402 | // Parent & course/session links |
||
| 1403 | if (method_exists($link, 'setParent')) { |
||
| 1404 | $link->setParent($course); |
||
| 1405 | } elseif (method_exists($link, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 1406 | $link->setParentResourceNode($course->getResourceNode()); |
||
| 1407 | } |
||
| 1408 | if (method_exists($link, 'addCourseLink')) { |
||
| 1409 | $link->addCourseLink($course, $session); |
||
| 1410 | } |
||
| 1411 | |||
| 1412 | // Attach category if it existed in Moodle |
||
| 1413 | $oldCatId = (int) ($L['old_cat_id'] ?? 0); |
||
| 1414 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 1415 | $link->setCategory($catMapByOldId[$oldCatId]); |
||
| 1416 | } |
||
| 1417 | |||
| 1418 | $em->persist($link); |
||
| 1419 | $newLinks++; |
||
| 1420 | } |
||
| 1421 | |||
| 1422 | $em->flush(); |
||
| 1423 | |||
| 1424 | if ($this->debug) { |
||
| 1425 | error_log('MOODLE_IMPORT[restoreLinks]: persisted='. |
||
| 1426 | json_encode(['cats' => $newCats, 'links' => $newLinks], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
||
| 1427 | } |
||
| 1428 | |||
| 1429 | return ['categories' => $newCats, 'links' => $newLinks]; |
||
| 1430 | } |
||
| 1431 | |||
| 1432 | /** |
||
| 1433 | * Fast-path: persist only Forum Categories and Forums from a Moodle backup, |
||
| 1434 | * wiring proper parents and course/session links with Doctrine entities. |
||
| 1435 | * |
||
| 1436 | * @return array{categories:int,forums:int} |
||
| 1437 | */ |
||
| 1438 | public function restoreForums( |
||
| 1439 | string $archivePath, |
||
| 1440 | EntityManagerInterface $em, |
||
| 1441 | int $courseRealId, |
||
| 1442 | int $sessionId = 0, |
||
| 1443 | ?object $courseArg = null |
||
| 1444 | ): array { |
||
| 1445 | /** @var CourseEntity|null $course */ |
||
| 1446 | $course = $em->getRepository(CourseEntity::class)->find($courseRealId); |
||
| 1447 | if (!$course) { |
||
| 1448 | throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')'); |
||
| 1449 | } |
||
| 1450 | |||
| 1451 | /** @var SessionEntity|null $session */ |
||
| 1452 | $session = $sessionId > 0 |
||
| 1453 | ? $em->getRepository(SessionEntity::class)->find($sessionId) |
||
| 1454 | : null; |
||
| 1455 | |||
| 1456 | // Fast-path: use filtered snapshot if provided (import/resources selection) |
||
| 1457 | if ($courseArg && isset($courseArg->resources) && \is_array($courseArg->resources)) { |
||
| 1458 | $forumsBucket = (array) ($courseArg->resources['forum'] ?? []); |
||
| 1459 | $catsBucket = (array) ($courseArg->resources['Forum_Category'] ?? []); |
||
| 1460 | |||
| 1461 | if (empty($forumsBucket)) { |
||
| 1462 | if ($this->debug) { |
||
| 1463 | error_log('MOODLE_IMPORT[restoreForums]: snapshot has no selected forums'); |
||
| 1464 | } |
||
| 1465 | |||
| 1466 | return ['categories' => 0, 'forums' => 0]; |
||
| 1467 | } |
||
| 1468 | |||
| 1469 | // Categories actually referenced by selected forums |
||
| 1470 | $usedCatIds = []; |
||
| 1471 | foreach ($forumsBucket as $F) { |
||
| 1472 | $oldCatId = (int) ($F->forum_category ?? 0); |
||
| 1473 | if ($oldCatId > 0) { |
||
| 1474 | $usedCatIds[$oldCatId] = true; |
||
| 1475 | } |
||
| 1476 | } |
||
| 1477 | |||
| 1478 | // Persist only needed categories |
||
| 1479 | $catMapByOldId = []; |
||
| 1480 | $newCats = 0; |
||
| 1481 | foreach ($catsBucket as $oldId => $C) { |
||
| 1482 | if (!isset($usedCatIds[$oldId])) { |
||
| 1483 | continue; |
||
| 1484 | } |
||
| 1485 | |||
| 1486 | $cat = (new CForumCategory()) |
||
| 1487 | ->setTitle((string) ($C->cat_title ?? $C->title ?? ('Category '.$oldId))) |
||
| 1488 | ->setCatComment((string) ($C->cat_comment ?? $C->description ?? '')) |
||
| 1489 | ->setParent($course) |
||
| 1490 | ->addCourseLink($course, $session) |
||
| 1491 | ; |
||
| 1492 | $em->persist($cat); |
||
| 1493 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 1494 | $newCats++; |
||
| 1495 | } |
||
| 1496 | if ($newCats > 0) { |
||
| 1497 | $em->flush(); |
||
| 1498 | } |
||
| 1499 | |||
| 1500 | // Fallback default category if none referenced |
||
| 1501 | $defaultCat = null; |
||
| 1502 | $ensureDefault = function () use (&$defaultCat, $course, $session, $em): CForumCategory { |
||
| 1503 | if ($defaultCat instanceof CForumCategory) { |
||
| 1504 | return $defaultCat; |
||
| 1505 | } |
||
| 1506 | $defaultCat = (new CForumCategory()) |
||
| 1507 | ->setTitle('General') |
||
| 1508 | ->setCatComment('') |
||
| 1509 | ->setParent($course) |
||
| 1510 | ->addCourseLink($course, $session) |
||
| 1511 | ; |
||
| 1512 | $em->persist($defaultCat); |
||
| 1513 | $em->flush(); |
||
| 1514 | |||
| 1515 | return $defaultCat; |
||
| 1516 | }; |
||
| 1517 | |||
| 1518 | // Persist selected forums |
||
| 1519 | $newForums = 0; |
||
| 1520 | foreach ($forumsBucket as $F) { |
||
| 1521 | $title = (string) ($F->forum_title ?? $F->title ?? 'Forum'); |
||
| 1522 | $comment = (string) ($F->forum_comment ?? $F->description ?? ''); |
||
| 1523 | |||
| 1524 | $dstCategory = null; |
||
| 1525 | $oldCatId = (int) ($F->forum_category ?? 0); |
||
| 1526 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 1527 | $dstCategory = $catMapByOldId[$oldCatId]; |
||
| 1528 | } elseif (1 === \count($catMapByOldId)) { |
||
| 1529 | $dstCategory = reset($catMapByOldId); |
||
| 1530 | } else { |
||
| 1531 | $dstCategory = $ensureDefault(); |
||
| 1532 | } |
||
| 1533 | |||
| 1534 | $forum = (new CForum()) |
||
| 1535 | ->setTitle($title) |
||
| 1536 | ->setForumComment($comment) |
||
| 1537 | ->setForumCategory($dstCategory) |
||
| 1538 | ->setAllowAttachments(1) |
||
| 1539 | ->setAllowNewThreads(1) |
||
| 1540 | ->setDefaultView('flat') |
||
| 1541 | ->setParent($dstCategory) |
||
| 1542 | ->addCourseLink($course, $session) |
||
| 1543 | ; |
||
| 1544 | |||
| 1545 | $em->persist($forum); |
||
| 1546 | $newForums++; |
||
| 1547 | } |
||
| 1548 | |||
| 1549 | $em->flush(); |
||
| 1550 | |||
| 1551 | if ($this->debug) { |
||
| 1552 | error_log('MOODLE_IMPORT[restoreForums]: persisted (snapshot) cats='.$newCats.' forums='.$newForums); |
||
| 1553 | } |
||
| 1554 | |||
| 1555 | return ['categories' => $newCats + ($defaultCat ? 1 : 0), 'forums' => $newForums]; |
||
| 1556 | } |
||
| 1557 | |||
| 1558 | [$workDir] = $this->extractToTemp($archivePath); |
||
| 1559 | |||
| 1560 | $mbx = $workDir.'/moodle_backup.xml'; |
||
| 1561 | if (!is_file($mbx)) { |
||
| 1562 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 1563 | } |
||
| 1564 | $mbDoc = $this->loadXml($mbx); |
||
| 1565 | $mb = new DOMXPath($mbDoc); |
||
| 1566 | |||
| 1567 | $forums = []; |
||
| 1568 | $categories = []; |
||
| 1569 | foreach ($mb->query('//activity') as $node) { |
||
| 1570 | /** @var DOMElement $node */ |
||
| 1571 | $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); |
||
| 1572 | if ('forum' !== $modName) { |
||
| 1573 | continue; |
||
| 1574 | } |
||
| 1575 | |||
| 1576 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 1577 | $moduleXml = ('' !== $dir) ? $workDir.'/'.$dir.'/forum.xml' : null; |
||
| 1578 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 1579 | if ($this->debug) { |
||
| 1580 | error_log('MOODLE_IMPORT[restoreForums]: skip (forum.xml not found)'); |
||
| 1581 | } |
||
| 1582 | |||
| 1583 | continue; |
||
| 1584 | } |
||
| 1585 | |||
| 1586 | $f = $this->readForumModule($moduleXml); |
||
| 1587 | |||
| 1588 | $oldCatId = (int) ($f['category_id'] ?? 0); |
||
| 1589 | $oldCatTitle = (string) ($f['category_title'] ?? ''); |
||
| 1590 | if ($oldCatId > 0 && !isset($categories[$oldCatId])) { |
||
| 1591 | $categories[$oldCatId] = [ |
||
| 1592 | 'title' => ('' !== $oldCatTitle ? $oldCatTitle : ('Category '.$oldCatId)), |
||
| 1593 | 'description' => '', |
||
| 1594 | ]; |
||
| 1595 | } |
||
| 1596 | |||
| 1597 | $forums[] = [ |
||
| 1598 | 'name' => (string) ($f['name'] ?? 'Forum'), |
||
| 1599 | 'description' => (string) ($f['description'] ?? ''), |
||
| 1600 | 'type' => (string) ($f['type'] ?? 'general'), |
||
| 1601 | 'old_cat_id' => $oldCatId, |
||
| 1602 | ]; |
||
| 1603 | } |
||
| 1604 | |||
| 1605 | if ($this->debug) { |
||
| 1606 | error_log('MOODLE_IMPORT[restoreForums]: found forums='.\count($forums).' cats='.\count($categories)); |
||
| 1607 | } |
||
| 1608 | |||
| 1609 | if (empty($forums) && empty($categories)) { |
||
| 1610 | return ['categories' => 0, 'forums' => 0]; |
||
| 1611 | } |
||
| 1612 | |||
| 1613 | $catMapByOldId = []; // oldCatId => CForumCategory |
||
| 1614 | $newCats = 0; |
||
| 1615 | |||
| 1616 | foreach ($categories as $oldId => $payload) { |
||
| 1617 | $cat = (new CForumCategory()) |
||
| 1618 | ->setTitle((string) $payload['title']) |
||
| 1619 | ->setCatComment((string) $payload['description']) |
||
| 1620 | ->setParent($course) |
||
| 1621 | ->addCourseLink($course, $session) |
||
| 1622 | ; |
||
| 1623 | $em->persist($cat); |
||
| 1624 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 1625 | $newCats++; |
||
| 1626 | } |
||
| 1627 | if ($newCats > 0) { |
||
| 1628 | $em->flush(); |
||
| 1629 | } |
||
| 1630 | |||
| 1631 | $defaultCat = null; |
||
| 1632 | $ensureDefault = function () use (&$defaultCat, $course, $session, $em): CForumCategory { |
||
| 1633 | if ($defaultCat instanceof CForumCategory) { |
||
| 1634 | return $defaultCat; |
||
| 1635 | } |
||
| 1636 | $defaultCat = (new CForumCategory()) |
||
| 1637 | ->setTitle('General') |
||
| 1638 | ->setCatComment('') |
||
| 1639 | ->setParent($course) |
||
| 1640 | ->addCourseLink($course, $session) |
||
| 1641 | ; |
||
| 1642 | $em->persist($defaultCat); |
||
| 1643 | $em->flush(); |
||
| 1644 | |||
| 1645 | return $defaultCat; |
||
| 1646 | }; |
||
| 1647 | |||
| 1648 | $newForums = 0; |
||
| 1649 | |||
| 1650 | foreach ($forums as $F) { |
||
| 1651 | $title = (string) ($F['name'] ?? 'Forum'); |
||
| 1652 | $comment = (string) ($F['description'] ?? ''); |
||
| 1653 | |||
| 1654 | $dstCategory = null; |
||
| 1655 | $oldCatId = (int) ($F['old_cat_id'] ?? 0); |
||
| 1656 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 1657 | $dstCategory = $catMapByOldId[$oldCatId]; |
||
| 1658 | } elseif (1 === \count($catMapByOldId)) { |
||
| 1659 | $dstCategory = reset($catMapByOldId); |
||
| 1660 | } else { |
||
| 1661 | $dstCategory = $ensureDefault(); |
||
| 1662 | } |
||
| 1663 | |||
| 1664 | $forum = (new CForum()) |
||
| 1665 | ->setTitle($title) |
||
| 1666 | ->setForumComment($comment) |
||
| 1667 | ->setForumCategory($dstCategory) |
||
| 1668 | ->setAllowAttachments(1) |
||
| 1669 | ->setAllowNewThreads(1) |
||
| 1670 | ->setDefaultView('flat') |
||
| 1671 | ->setParent($dstCategory) |
||
| 1672 | ->addCourseLink($course, $session) |
||
| 1673 | ; |
||
| 1674 | |||
| 1675 | $em->persist($forum); |
||
| 1676 | $newForums++; |
||
| 1677 | } |
||
| 1678 | |||
| 1679 | $em->flush(); |
||
| 1680 | |||
| 1681 | if ($this->debug) { |
||
| 1682 | error_log('MOODLE_IMPORT[restoreForums]: persisted cats='.$newCats.' forums='.$newForums); |
||
| 1683 | } |
||
| 1684 | |||
| 1685 | return ['categories' => $newCats, 'forums' => $newForums]; |
||
| 1686 | } |
||
| 1687 | |||
| 1688 | /** |
||
| 1689 | * Fast-path: restore only Documents from a Moodle backup, wiring ResourceFiles directly. |
||
| 1690 | * CHANGE: We already normalize paths and explicitly strip a leading "Documents/" segment, |
||
| 1691 | * so the Moodle top-level "Documents" folder is treated as the document root in Chamilo. |
||
| 1692 | */ |
||
| 1693 | public function restoreDocuments( |
||
| 1694 | string $archivePath, |
||
| 1695 | EntityManagerInterface $em, |
||
| 1696 | int $courseRealId, |
||
| 1697 | int $sessionId = 0, |
||
| 1698 | int $sameFileNameOption = 2, |
||
| 1699 | ?object $courseArg = null |
||
| 1700 | ): array { |
||
| 1701 | // Use filtered snapshot if provided; otherwise build from archive |
||
| 1702 | $legacy = $courseArg ?: $this->buildLegacyCourseFromMoodleArchive($archivePath); |
||
| 1703 | |||
| 1704 | if (!\defined('FILE_SKIP')) { |
||
| 1705 | \define('FILE_SKIP', 1); |
||
| 1706 | } |
||
| 1707 | if (!\defined('FILE_RENAME')) { |
||
| 1708 | \define('FILE_RENAME', 2); |
||
| 1709 | } |
||
| 1710 | if (!\defined('FILE_OVERWRITE')) { |
||
| 1711 | \define('FILE_OVERWRITE', 3); |
||
| 1712 | } |
||
| 1713 | $filePolicy = \in_array($sameFileNameOption, [1, 2, 3], true) ? $sameFileNameOption : FILE_RENAME; |
||
| 1714 | |||
| 1715 | /** @var CDocumentRepository $docRepo */ |
||
| 1716 | $docRepo = Container::getDocumentRepository(); |
||
| 1717 | $courseEntity = api_get_course_entity($courseRealId); |
||
| 1718 | $sessionEntity = api_get_session_entity((int) $sessionId); |
||
| 1719 | $groupEntity = api_get_group_entity(0); |
||
| 1720 | |||
| 1721 | if (!$courseEntity) { |
||
| 1722 | throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')'); |
||
| 1723 | } |
||
| 1724 | |||
| 1725 | $srcRoot = rtrim((string) ($legacy->backup_path ?? ''), '/').'/'; |
||
| 1726 | if (!is_dir($srcRoot)) { |
||
| 1727 | throw new RuntimeException('Moodle working directory not found: '.$srcRoot); |
||
| 1728 | } |
||
| 1729 | |||
| 1730 | $docs = []; |
||
| 1731 | if (!empty($legacy->resources['document']) && \is_array($legacy->resources['document'])) { |
||
| 1732 | $docs = $legacy->resources['document']; |
||
| 1733 | } elseif (!empty($legacy->resources['Document']) && \is_array($legacy->resources['Document'])) { |
||
| 1734 | $docs = $legacy->resources['Document']; |
||
| 1735 | } |
||
| 1736 | if (empty($docs)) { |
||
| 1737 | if ($this->debug) { |
||
| 1738 | error_log('MOODLE_IMPORT[restoreDocuments]: no document bucket found'); |
||
| 1739 | } |
||
| 1740 | |||
| 1741 | return ['documents' => 0, 'folders' => 0]; |
||
| 1742 | } |
||
| 1743 | |||
| 1744 | $courseInfo = api_get_course_info(); |
||
| 1745 | $courseDir = (string) ($courseInfo['directory'] ?? $courseInfo['code'] ?? ''); |
||
| 1746 | |||
| 1747 | $DBG = function (string $msg, array $ctx = []): void { |
||
| 1748 | error_log('[MOODLE_IMPORT:RESTORE_DOCS] '.$msg.(empty($ctx) ? '' : ' '.json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))); |
||
| 1749 | }; |
||
| 1750 | |||
| 1751 | // Path normalizer: strip moodle-specific top-level segments like t/, moodle_pages/, Documents/ |
||
| 1752 | // NOTE: This is what makes "Documents" behave as root in Chamilo. |
||
| 1753 | $normalizeMoodleRel = static function (string $rawPath): string { |
||
| 1754 | $p = ltrim($rawPath, '/'); |
||
| 1755 | |||
| 1756 | // Drop "document/" prefix if present |
||
| 1757 | if (str_starts_with($p, 'document/')) { |
||
| 1758 | $p = substr($p, 9); |
||
| 1759 | } |
||
| 1760 | |||
| 1761 | // Strip known moodle export prefixes (order matters: most specific first) |
||
| 1762 | $strip = ['t/', 'moodle_pages/', 'Documents/']; |
||
| 1763 | foreach ($strip as $pre) { |
||
| 1764 | if (str_starts_with($p, $pre)) { |
||
| 1765 | $p = substr($p, \strlen($pre)); |
||
| 1766 | } |
||
| 1767 | } |
||
| 1768 | |||
| 1769 | $p = ltrim($p, '/'); |
||
| 1770 | |||
| 1771 | return '' === $p ? '/' : '/'.$p; |
||
| 1772 | }; |
||
| 1773 | |||
| 1774 | $isFolderItem = static function (object $item): bool { |
||
| 1775 | $e = (isset($item->obj) && \is_object($item->obj)) ? $item->obj : $item; |
||
| 1776 | $ft = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); |
||
| 1777 | if ('folder' === $ft) { |
||
| 1778 | return true; |
||
| 1779 | } |
||
| 1780 | $p = (string) ($e->path ?? ''); |
||
| 1781 | |||
| 1782 | return '' !== $p && '/' === substr($p, -1); |
||
| 1783 | }; |
||
| 1784 | $effectiveEntity = static function (object $item): object { |
||
| 1785 | return (isset($item->obj) && \is_object($item->obj)) ? $item->obj : $item; |
||
| 1786 | }; |
||
| 1787 | |||
| 1788 | // Ensure folder chain and return destination parent iid |
||
| 1789 | $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $sessionId, $DBG) { |
||
| 1790 | $rel = '/'.ltrim($relPath, '/'); |
||
| 1791 | if ('/' === $rel || '' === $rel) { |
||
| 1792 | return 0; |
||
| 1793 | } |
||
| 1794 | $parts = array_values(array_filter(explode('/', trim($rel, '/')))); |
||
| 1795 | |||
| 1796 | // If first segment is "document", skip it; we are already under the course document root. |
||
| 1797 | $start = (isset($parts[0]) && 'document' === strtolower($parts[0])) ? 1 : 0; |
||
| 1798 | |||
| 1799 | $accum = ''; |
||
| 1800 | $parentId = 0; |
||
| 1801 | for ($i = $start; $i < \count($parts); $i++) { |
||
|
|
|||
| 1802 | $seg = $parts[$i]; |
||
| 1803 | $accum = $accum.'/'.$seg; |
||
| 1804 | $title = $seg; |
||
| 1805 | $parent = $parentId ? $docRepo->find($parentId) : $courseEntity; |
||
| 1806 | |||
| 1807 | $existing = $docRepo->findCourseResourceByTitle( |
||
| 1808 | $title, |
||
| 1809 | $parent->getResourceNode(), |
||
| 1810 | $courseEntity, |
||
| 1811 | api_get_session_entity((int) $sessionId), |
||
| 1812 | api_get_group_entity(0) |
||
| 1813 | ); |
||
| 1814 | |||
| 1815 | if ($existing) { |
||
| 1816 | $parentId = method_exists($existing, 'getIid') ? (int) $existing->getIid() : 0; |
||
| 1817 | |||
| 1818 | continue; |
||
| 1819 | } |
||
| 1820 | |||
| 1821 | $entity = DocumentManager::addDocument( |
||
| 1822 | ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']], |
||
| 1823 | $accum, |
||
| 1824 | 'folder', |
||
| 1825 | 0, |
||
| 1826 | $title, |
||
| 1827 | null, |
||
| 1828 | 0, |
||
| 1829 | null, |
||
| 1830 | 0, |
||
| 1831 | (int) $sessionId, |
||
| 1832 | 0, |
||
| 1833 | false, |
||
| 1834 | '', |
||
| 1835 | $parentId, |
||
| 1836 | '' |
||
| 1837 | ); |
||
| 1838 | $parentId = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; |
||
| 1839 | $DBG('ensureFolder:create', ['accum' => $accum, 'iid' => $parentId]); |
||
| 1840 | } |
||
| 1841 | |||
| 1842 | return $parentId; |
||
| 1843 | }; |
||
| 1844 | |||
| 1845 | $isHtmlFile = static function (string $filePath, string $nameGuess): bool { |
||
| 1846 | $ext1 = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); |
||
| 1847 | $ext2 = strtolower(pathinfo($nameGuess, PATHINFO_EXTENSION)); |
||
| 1848 | if (\in_array($ext1, ['html', 'htm'], true) || \in_array($ext2, ['html', 'htm'], true)) { |
||
| 1849 | return true; |
||
| 1850 | } |
||
| 1851 | $peek = (string) @file_get_contents($filePath, false, null, 0, 2048); |
||
| 1852 | if ('' === $peek) { |
||
| 1853 | return false; |
||
| 1854 | } |
||
| 1855 | $s = strtolower($peek); |
||
| 1856 | if (str_contains($s, '<html') || str_contains($s, '<!doctype html')) { |
||
| 1857 | return true; |
||
| 1858 | } |
||
| 1859 | if (\function_exists('finfo_open')) { |
||
| 1860 | $fi = finfo_open(FILEINFO_MIME_TYPE); |
||
| 1861 | if ($fi) { |
||
| 1862 | $mt = @finfo_buffer($fi, $peek) ?: ''; |
||
| 1863 | finfo_close($fi); |
||
| 1864 | if (str_starts_with($mt, 'text/html')) { |
||
| 1865 | return true; |
||
| 1866 | } |
||
| 1867 | } |
||
| 1868 | } |
||
| 1869 | |||
| 1870 | return false; |
||
| 1871 | }; |
||
| 1872 | |||
| 1873 | // Create folders (preserve tree) with normalized paths; track destination iids |
||
| 1874 | $folders = []; // map: normalized folder rel -> iid |
||
| 1875 | $nFolders = 0; |
||
| 1876 | |||
| 1877 | foreach ($docs as $k => $wrap) { |
||
| 1878 | $e = $effectiveEntity($wrap); |
||
| 1879 | if (!$isFolderItem($wrap)) { |
||
| 1880 | continue; |
||
| 1881 | } |
||
| 1882 | |||
| 1883 | $rawPath = (string) ($e->path ?? ''); |
||
| 1884 | if ('' === $rawPath) { |
||
| 1885 | continue; |
||
| 1886 | } |
||
| 1887 | |||
| 1888 | // Normalize to avoid 't/', 'moodle_pages/', 'Documents/' phantom roots |
||
| 1889 | $rel = $normalizeMoodleRel($rawPath); |
||
| 1890 | if ('/' === $rel) { |
||
| 1891 | continue; |
||
| 1892 | } |
||
| 1893 | |||
| 1894 | $parts = array_values(array_filter(explode('/', trim($rel, '/')))); |
||
| 1895 | $accum = ''; |
||
| 1896 | $parentId = 0; |
||
| 1897 | |||
| 1898 | foreach ($parts as $i => $seg) { |
||
| 1899 | $accum .= '/'.$seg; |
||
| 1900 | if (isset($folders[$accum])) { |
||
| 1901 | $parentId = $folders[$accum]; |
||
| 1902 | |||
| 1903 | continue; |
||
| 1904 | } |
||
| 1905 | |||
| 1906 | $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; |
||
| 1907 | $title = ($i === \count($parts) - 1) ? ((string) ($e->title ?? $seg)) : $seg; |
||
| 1908 | |||
| 1909 | $existing = $docRepo->findCourseResourceByTitle( |
||
| 1910 | $title, |
||
| 1911 | $parentRes->getResourceNode(), |
||
| 1912 | $courseEntity, |
||
| 1913 | $sessionEntity, |
||
| 1914 | $groupEntity |
||
| 1915 | ); |
||
| 1916 | |||
| 1917 | if ($existing) { |
||
| 1918 | $iid = method_exists($existing, 'getIid') ? (int) $existing->getIid() : 0; |
||
| 1919 | $DBG('folder:reuse', ['title' => $title, 'iid' => $iid]); |
||
| 1920 | } else { |
||
| 1921 | $entity = DocumentManager::addDocument( |
||
| 1922 | ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']], |
||
| 1923 | $accum, |
||
| 1924 | 'folder', |
||
| 1925 | 0, |
||
| 1926 | $title, |
||
| 1927 | null, |
||
| 1928 | 0, |
||
| 1929 | null, |
||
| 1930 | 0, |
||
| 1931 | (int) $sessionId, |
||
| 1932 | 0, |
||
| 1933 | false, |
||
| 1934 | '', |
||
| 1935 | $parentId, |
||
| 1936 | '' |
||
| 1937 | ); |
||
| 1938 | $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; |
||
| 1939 | $DBG('folder:create', ['title' => $title, 'iid' => $iid]); |
||
| 1940 | $nFolders++; |
||
| 1941 | } |
||
| 1942 | |||
| 1943 | $folders[$accum] = $iid; |
||
| 1944 | $parentId = $iid; |
||
| 1945 | } |
||
| 1946 | |||
| 1947 | if (isset($legacy->resources['document'][$k])) { |
||
| 1948 | $legacy->resources['document'][$k]->destination_id = $parentId; |
||
| 1949 | } |
||
| 1950 | } |
||
| 1951 | |||
| 1952 | // PRE-SCAN: build URL maps for HTML rewriting if helpers exist |
||
| 1953 | $urlMapByRel = []; |
||
| 1954 | $urlMapByBase = []; |
||
| 1955 | foreach ($docs as $k => $wrap) { |
||
| 1956 | $e = $effectiveEntity($wrap); |
||
| 1957 | if ($isFolderItem($wrap)) { |
||
| 1958 | continue; |
||
| 1959 | } |
||
| 1960 | |||
| 1961 | $title = (string) ($e->title ?? basename((string) $e->path)); |
||
| 1962 | $src = $srcRoot.(string) $e->path; |
||
| 1963 | |||
| 1964 | if (!is_file($src) || !is_readable($src)) { |
||
| 1965 | continue; |
||
| 1966 | } |
||
| 1967 | if (!$isHtmlFile($src, $title)) { |
||
| 1968 | continue; |
||
| 1969 | } |
||
| 1970 | |||
| 1971 | $html = (string) @file_get_contents($src); |
||
| 1972 | if ('' === $html) { |
||
| 1973 | continue; |
||
| 1974 | } |
||
| 1975 | |||
| 1976 | try { |
||
| 1977 | $maps = ChamiloHelper::buildUrlMapForHtmlFromPackage( |
||
| 1978 | $html, |
||
| 1979 | $courseDir, |
||
| 1980 | $srcRoot, |
||
| 1981 | $folders, |
||
| 1982 | $ensureFolder, |
||
| 1983 | $docRepo, |
||
| 1984 | $courseEntity, |
||
| 1985 | $sessionEntity, |
||
| 1986 | $groupEntity, |
||
| 1987 | (int) $sessionId, |
||
| 1988 | (int) $filePolicy, |
||
| 1989 | $DBG |
||
| 1990 | ); |
||
| 1991 | |||
| 1992 | foreach ($maps['byRel'] ?? [] as $kRel => $vUrl) { |
||
| 1993 | if (!isset($urlMapByRel[$kRel])) { |
||
| 1994 | $urlMapByRel[$kRel] = $vUrl; |
||
| 1995 | } |
||
| 1996 | } |
||
| 1997 | foreach ($maps['byBase'] ?? [] as $kBase => $vUrl) { |
||
| 1998 | if (!isset($urlMapByBase[$kBase])) { |
||
| 1999 | $urlMapByBase[$kBase] = $vUrl; |
||
| 2000 | } |
||
| 2001 | } |
||
| 2002 | } catch (Throwable $te) { |
||
| 2003 | $DBG('html:map:failed', ['err' => $te->getMessage()]); |
||
| 2004 | } |
||
| 2005 | } |
||
| 2006 | $DBG('global.map.stats', ['byRel' => \count($urlMapByRel), 'byBase' => \count($urlMapByBase)]); |
||
| 2007 | |||
| 2008 | // Import files (HTML rewritten before addDocument; binaries via realPath) |
||
| 2009 | $nFiles = 0; |
||
| 2010 | foreach ($docs as $k => $wrap) { |
||
| 2011 | $e = $effectiveEntity($wrap); |
||
| 2012 | if ($isFolderItem($wrap)) { |
||
| 2013 | continue; |
||
| 2014 | } |
||
| 2015 | |||
| 2016 | $rawTitle = (string) ($e->title ?? basename((string) $e->path)); |
||
| 2017 | $srcPath = $srcRoot.(string) $e->path; |
||
| 2018 | |||
| 2019 | if (!is_file($srcPath) || !is_readable($srcPath)) { |
||
| 2020 | $DBG('file:skip:src-missing', ['src' => $srcPath, 'title' => $rawTitle]); |
||
| 2021 | |||
| 2022 | continue; |
||
| 2023 | } |
||
| 2024 | |||
| 2025 | // Parent folder: from normalized path (this strips "Documents/") |
||
| 2026 | $rel = $normalizeMoodleRel((string) $e->path); |
||
| 2027 | $parentRel = rtrim(\dirname($rel), '/'); |
||
| 2028 | $parentId = $folders[$parentRel] ?? 0; |
||
| 2029 | if (!$parentId) { |
||
| 2030 | $parentId = $ensureFolder($parentRel); |
||
| 2031 | $folders[$parentRel] = $parentId; |
||
| 2032 | } |
||
| 2033 | $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; |
||
| 2034 | |||
| 2035 | // Handle name collisions based on $filePolicy |
||
| 2036 | $findExistingIid = function (string $title) use ($docRepo, $parentRes, $courseEntity, $sessionEntity, $groupEntity): ?int { |
||
| 2037 | $ex = $docRepo->findCourseResourceByTitle( |
||
| 2038 | $title, |
||
| 2039 | $parentRes->getResourceNode(), |
||
| 2040 | $courseEntity, |
||
| 2041 | $sessionEntity, |
||
| 2042 | $groupEntity |
||
| 2043 | ); |
||
| 2044 | |||
| 2045 | return $ex && method_exists($ex, 'getIid') ? (int) $ex->getIid() : null; |
||
| 2046 | }; |
||
| 2047 | |||
| 2048 | $baseTitle = $rawTitle; |
||
| 2049 | $finalTitle = $baseTitle; |
||
| 2050 | |||
| 2051 | $existsIid = $findExistingIid($finalTitle); |
||
| 2052 | if ($existsIid) { |
||
| 2053 | $DBG('file:collision', ['title' => $finalTitle, 'policy' => $filePolicy]); |
||
| 2054 | if (FILE_SKIP === $filePolicy) { |
||
| 2055 | if (isset($legacy->resources['document'][$k])) { |
||
| 2056 | $legacy->resources['document'][$k]->destination_id = $existsIid; |
||
| 2057 | } |
||
| 2058 | |||
| 2059 | continue; |
||
| 2060 | } |
||
| 2061 | if (FILE_RENAME === $filePolicy) { |
||
| 2062 | $pi = pathinfo($baseTitle); |
||
| 2063 | $name = $pi['filename'] ?? $baseTitle; |
||
| 2064 | $ext2 = isset($pi['extension']) && '' !== $pi['extension'] ? '.'.$pi['extension'] : ''; |
||
| 2065 | $i = 1; |
||
| 2066 | while ($findExistingIid($finalTitle)) { |
||
| 2067 | $finalTitle = $name.'_'.$i.$ext2; |
||
| 2068 | $i++; |
||
| 2069 | } |
||
| 2070 | } |
||
| 2071 | // FILE_OVERWRITE => let DocumentManager handle it |
||
| 2072 | } |
||
| 2073 | |||
| 2074 | // Prepare payload for addDocument |
||
| 2075 | $isHtml = $isHtmlFile($srcPath, $rawTitle); |
||
| 2076 | $content = ''; |
||
| 2077 | $realPath = ''; |
||
| 2078 | |||
| 2079 | if ($isHtml) { |
||
| 2080 | $raw = @file_get_contents($srcPath) ?: ''; |
||
| 2081 | if (\defined('UTF8_CONVERT') && UTF8_CONVERT) { |
||
| 2082 | $raw = utf8_encode($raw); |
||
| 2083 | } |
||
| 2084 | $DBG('html:rewrite:before', ['title' => $finalTitle, 'maps' => [\count($urlMapByRel), \count($urlMapByBase)]]); |
||
| 2085 | |||
| 2086 | try { |
||
| 2087 | $rew = ChamiloHelper::rewriteLegacyCourseUrlsWithMap( |
||
| 2088 | $raw, |
||
| 2089 | $courseDir, |
||
| 2090 | $urlMapByRel, |
||
| 2091 | $urlMapByBase |
||
| 2092 | ); |
||
| 2093 | $content = (string) ($rew['html'] ?? $raw); |
||
| 2094 | $DBG('html:rewrite:after', ['replaced' => (int) ($rew['replaced'] ?? 0), 'misses' => (int) ($rew['misses'] ?? 0)]); |
||
| 2095 | } catch (Throwable $te) { |
||
| 2096 | $content = $raw; // fallback to original HTML |
||
| 2097 | $DBG('html:rewrite:error', ['err' => $te->getMessage()]); |
||
| 2098 | } |
||
| 2099 | } else { |
||
| 2100 | $realPath = $srcPath; // binary: pass physical path to be streamed into ResourceFile |
||
| 2101 | } |
||
| 2102 | |||
| 2103 | try { |
||
| 2104 | $entity = DocumentManager::addDocument( |
||
| 2105 | ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']], |
||
| 2106 | $rel, |
||
| 2107 | 'file', |
||
| 2108 | (int) ($e->size ?? 0), |
||
| 2109 | $finalTitle, |
||
| 2110 | (string) ($e->comment ?? ''), |
||
| 2111 | 0, |
||
| 2112 | null, |
||
| 2113 | 0, |
||
| 2114 | (int) $sessionId, |
||
| 2115 | 0, |
||
| 2116 | false, |
||
| 2117 | $content, |
||
| 2118 | $parentId, |
||
| 2119 | $realPath |
||
| 2120 | ); |
||
| 2121 | $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; |
||
| 2122 | |||
| 2123 | if (isset($legacy->resources['document'][$k])) { |
||
| 2124 | $legacy->resources['document'][$k]->destination_id = $iid; |
||
| 2125 | } |
||
| 2126 | |||
| 2127 | $nFiles++; |
||
| 2128 | $DBG('file:created', ['title' => $finalTitle, 'iid' => $iid, 'html' => $isHtml ? 1 : 0]); |
||
| 2129 | } catch (Throwable $eX) { |
||
| 2130 | $DBG('file:create:failed', ['title' => $finalTitle, 'error' => $eX->getMessage()]); |
||
| 2131 | } |
||
| 2132 | } |
||
| 2133 | |||
| 2134 | $DBG('summary', ['files' => $nFiles, 'folders' => $nFolders]); |
||
| 2135 | |||
| 2136 | return ['documents' => $nFiles, 'folders' => $nFolders]; |
||
| 2137 | } |
||
| 2138 | |||
| 2139 | /** |
||
| 2140 | * Read documents from activities/resource + files.xml and populate $resources['document']. |
||
| 2141 | * NEW behavior: |
||
| 2142 | * - Treat Moodle's top-level "Documents" folder as the ROOT of /document (do NOT create a "Documents" node). |
||
| 2143 | * - Preserve any real subfolders beneath "Documents/". |
||
| 2144 | * - Copies blobs from files/<hash> to the target /document/... path |
||
| 2145 | * - Adds LP items when section map exists. |
||
| 2146 | */ |
||
| 2147 | private function readDocuments( |
||
| 2148 | string $workDir, |
||
| 2149 | DOMXPath $mb, |
||
| 2150 | array $fileIndex, |
||
| 2151 | array &$resources, |
||
| 2152 | array &$lpMap |
||
| 2153 | ): void { |
||
| 2154 | $resources['document'] ??= []; |
||
| 2155 | |||
| 2156 | // Ensure physical /document dir exists in the working dir (snapshot points there). |
||
| 2157 | $this->ensureDir($workDir.'/document'); |
||
| 2158 | |||
| 2159 | // Helper: strip an optional leading "/Documents" segment *once* |
||
| 2160 | $stripDocumentsRoot = static function (string $p): string { |
||
| 2161 | $p = '/'.ltrim($p, '/'); |
||
| 2162 | if (preg_match('~^/Documents(/|$)~i', $p)) { |
||
| 2163 | $p = substr($p, \strlen('/Documents')); |
||
| 2164 | if (false === $p) { |
||
| 2165 | $p = '/'; |
||
| 2166 | } |
||
| 2167 | } |
||
| 2168 | |||
| 2169 | return '' === $p ? '/' : $p; |
||
| 2170 | }; |
||
| 2171 | |||
| 2172 | // Small helper: ensure folder chain (legacy snapshot + filesystem) under /document, |
||
| 2173 | // skipping an initial "Documents" segment if present. |
||
| 2174 | $ensureFolderChain = function (string $base, string $fp) use (&$resources, $workDir, $stripDocumentsRoot): string { |
||
| 2175 | // Normalize base and fp |
||
| 2176 | $base = rtrim($base, '/'); // expected "/document" |
||
| 2177 | $fp = $this->normalizeSlash($fp ?: '/'); // "/sub/dir/" or "/" |
||
| 2178 | $fp = $stripDocumentsRoot($fp); |
||
| 2179 | |||
| 2180 | if ('/' === $fp || '' === $fp) { |
||
| 2181 | // Just the base /document |
||
| 2182 | $this->ensureDir($workDir.$base); |
||
| 2183 | |||
| 2184 | return $base; |
||
| 2185 | } |
||
| 2186 | |||
| 2187 | // Split and ensure each segment (both on disk and in legacy snapshot) |
||
| 2188 | $parts = array_values(array_filter(explode('/', trim($fp, '/')))); |
||
| 2189 | $accRel = $base; |
||
| 2190 | foreach ($parts as $seg) { |
||
| 2191 | $accRel .= '/'.$seg; |
||
| 2192 | // Create on disk |
||
| 2193 | $this->ensureDir($workDir.$accRel); |
||
| 2194 | // Create in legacy snapshot as a folder node (idempotent) |
||
| 2195 | $this->ensureFolderLegacy($resources['document'], $accRel, $seg); |
||
| 2196 | } |
||
| 2197 | |||
| 2198 | return $accRel; // final parent folder rel path (under /document) |
||
| 2199 | }; |
||
| 2200 | |||
| 2201 | // A) Restore "resource" activities (single-file resources) |
||
| 2202 | foreach ($mb->query('//activity[modulename="resource"]') as $node) { |
||
| 2203 | /** @var DOMElement $node */ |
||
| 2204 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 2205 | if ('' === $dir) { |
||
| 2206 | continue; |
||
| 2207 | } |
||
| 2208 | |||
| 2209 | $resourceXml = $workDir.'/'.$dir.'/resource.xml'; |
||
| 2210 | $inforefXml = $workDir.'/'.$dir.'/inforef.xml'; |
||
| 2211 | if (!is_file($resourceXml) || !is_file($inforefXml)) { |
||
| 2212 | continue; |
||
| 2213 | } |
||
| 2214 | |||
| 2215 | // 1) Read resource name/intro |
||
| 2216 | [$resName, $resIntro] = $this->readResourceMeta($resourceXml); |
||
| 2217 | |||
| 2218 | // 2) Resolve referenced file ids |
||
| 2219 | $fileIds = $this->parseInforefFileIds($inforefXml); |
||
| 2220 | if (empty($fileIds)) { |
||
| 2221 | continue; |
||
| 2222 | } |
||
| 2223 | |||
| 2224 | foreach ($fileIds as $fid) { |
||
| 2225 | $f = $fileIndex['byId'][$fid] ?? null; |
||
| 2226 | if (!$f) { |
||
| 2227 | continue; |
||
| 2228 | } |
||
| 2229 | |||
| 2230 | // Keep original structure from files.xml under /document (NOT /document/Documents) |
||
| 2231 | $fp = $this->normalizeSlash($f['filepath'] ?? '/'); // e.g. "/sub/dir/" |
||
| 2232 | $fp = $stripDocumentsRoot($fp); |
||
| 2233 | $base = '/document'; // root in Chamilo |
||
| 2234 | $parentRel = $ensureFolderChain($base, $fp); |
||
| 2235 | |||
| 2236 | $fileName = ltrim((string) ($f['filename'] ?? ''), '/'); |
||
| 2237 | if ('' === $fileName) { |
||
| 2238 | $fileName = 'file_'.$fid; |
||
| 2239 | } |
||
| 2240 | $targetRel = rtrim($parentRel, '/').'/'.$fileName; |
||
| 2241 | $targetAbs = $workDir.$targetRel; |
||
| 2242 | |||
| 2243 | // Copy binary into working dir |
||
| 2244 | $this->ensureDir(\dirname($targetAbs)); |
||
| 2245 | $this->safeCopy($f['blob'], $targetAbs); |
||
| 2246 | |||
| 2247 | // Register in legacy snapshot |
||
| 2248 | $docId = $this->nextId($resources['document']); |
||
| 2249 | $resources['document'][$docId] = $this->mkLegacyItem( |
||
| 2250 | 'document', |
||
| 2251 | $docId, |
||
| 2252 | [ |
||
| 2253 | 'file_type' => 'file', |
||
| 2254 | 'path' => $targetRel, |
||
| 2255 | 'title' => ('' !== $resName ? $resName : (string) $fileName), |
||
| 2256 | 'comment' => $resIntro, |
||
| 2257 | 'size' => (string) ($f['filesize'] ?? 0), |
||
| 2258 | ] |
||
| 2259 | ); |
||
| 2260 | |||
| 2261 | // Add to LP of the section, if present (keeps current behavior) |
||
| 2262 | $sectionId = (int) ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); |
||
| 2263 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 2264 | $resourcesDocTitle = $resources['document'][$docId]->title ?? (string) $fileName; |
||
| 2265 | $lpMap[$sectionId]['items'][] = [ |
||
| 2266 | 'item_type' => 'document', |
||
| 2267 | 'ref' => $docId, |
||
| 2268 | 'title' => $resourcesDocTitle, |
||
| 2269 | ]; |
||
| 2270 | } |
||
| 2271 | } |
||
| 2272 | } |
||
| 2273 | |||
| 2274 | // B) Restore files that belong to mod_folder activities. |
||
| 2275 | foreach ($fileIndex['byId'] as $f) { |
||
| 2276 | if (($f['component'] ?? '') !== 'mod_folder') { |
||
| 2277 | continue; |
||
| 2278 | } |
||
| 2279 | |||
| 2280 | // Keep inner structure from files.xml under /document; strip leading "Documents/" |
||
| 2281 | $fp = $this->normalizeSlash($f['filepath'] ?? '/'); // e.g. "/unit1/slide/" |
||
| 2282 | $fp = $stripDocumentsRoot($fp); |
||
| 2283 | $base = '/document'; |
||
| 2284 | |||
| 2285 | // Ensure folder chain exists on disk and in legacy map; get parent rel |
||
| 2286 | $parentRel = $ensureFolderChain($base, $fp); |
||
| 2287 | |||
| 2288 | // Final rel path for the file |
||
| 2289 | $fileName = ltrim((string) ($f['filename'] ?? ''), '/'); |
||
| 2290 | if ('' === $fileName) { |
||
| 2291 | // Defensive: generate name if missing (rare, but keeps import resilient) |
||
| 2292 | $fileName = 'file_'.$this->nextId($resources['document']); |
||
| 2293 | } |
||
| 2294 | $rel = rtrim($parentRel, '/').'/'.$fileName; |
||
| 2295 | |||
| 2296 | // Copy to working dir |
||
| 2297 | $abs = $workDir.$rel; |
||
| 2298 | $this->ensureDir(\dirname($abs)); |
||
| 2299 | $this->safeCopy($f['blob'], $abs); |
||
| 2300 | |||
| 2301 | // Register the file in legacy snapshot (folder nodes were created by ensureFolderChain) |
||
| 2302 | $docId = $this->nextId($resources['document']); |
||
| 2303 | $resources['document'][$docId] = $this->mkLegacyItem( |
||
| 2304 | 'document', |
||
| 2305 | $docId, |
||
| 2306 | [ |
||
| 2307 | 'file_type' => 'file', |
||
| 2308 | 'path' => $rel, |
||
| 2309 | 'title' => (string) ($fileName ?: 'file '.$docId), |
||
| 2310 | 'size' => (string) ($f['filesize'] ?? 0), |
||
| 2311 | 'comment' => '', |
||
| 2312 | ] |
||
| 2313 | ); |
||
| 2314 | } |
||
| 2315 | } |
||
| 2316 | |||
| 2317 | /** |
||
| 2318 | * Extract resource name and intro from activities/resource/resource.xml. |
||
| 2319 | */ |
||
| 2320 | private function readResourceMeta(string $resourceXml): array |
||
| 2328 | } |
||
| 2329 | |||
| 2330 | /** |
||
| 2331 | * Parse file ids referenced by inforef.xml (<inforef><fileref><file><id>..</id>). |
||
| 2332 | */ |
||
| 2333 | private function parseInforefFileIds(string $inforefXml): array |
||
| 2346 | } |
||
| 2347 | |||
| 2348 | /** |
||
| 2349 | * Create (if missing) a legacy folder entry at $folderPath in $bucket and return its id. |
||
| 2350 | */ |
||
| 2351 | private function ensureFolderLegacy(array &$bucket, string $folderPath, string $title): int |
||
| 2352 | { |
||
| 2353 | foreach ($bucket as $k => $it) { |
||
| 2354 | if (($it->file_type ?? '') === 'folder' && (($it->path ?? '') === $folderPath)) { |
||
| 2355 | return (int) $k; |
||
| 2356 | } |
||
| 2357 | } |
||
| 2358 | $id = $this->nextId($bucket); |
||
| 2359 | $bucket[$id] = $this->mkLegacyItem('document', $id, [ |
||
| 2360 | 'file_type' => 'folder', |
||
| 2361 | 'path' => $folderPath, |
||
| 2362 | 'title' => $title, |
||
| 2363 | 'size' => '0', |
||
| 2364 | ]); |
||
| 2365 | |||
| 2366 | return $id; |
||
| 2367 | } |
||
| 2368 | |||
| 2369 | /** |
||
| 2370 | * Copy a file if present (tolerant if blob is missing). |
||
| 2371 | */ |
||
| 2372 | private function safeCopy(string $src, string $dst): void |
||
| 2373 | { |
||
| 2374 | if (!is_file($src)) { |
||
| 2375 | if ($this->debug) { |
||
| 2376 | error_log('MOODLE_IMPORT: blob not found: '.$src); |
||
| 2377 | } |
||
| 2378 | |||
| 2379 | return; |
||
| 2380 | } |
||
| 2381 | if (!is_file($dst)) { |
||
| 2382 | @copy($src, $dst); |
||
| 2383 | } |
||
| 2384 | } |
||
| 2385 | |||
| 2386 | /** |
||
| 2387 | * Normalize a path to have single slashes and end with a slash. |
||
| 2388 | */ |
||
| 2389 | private function normalizeSlash(string $p): string |
||
| 2390 | { |
||
| 2391 | if ('' === $p || '.' === $p) { |
||
| 2392 | return '/'; |
||
| 2393 | } |
||
| 2394 | $p = preg_replace('#/+#', '/', $p); |
||
| 2395 | |||
| 2396 | return rtrim($p, '/').'/'; |
||
| 2397 | } |
||
| 2398 | |||
| 2399 | private function mkLegacyItem(string $type, int $sourceId, array|object $obj, array $arrayKeysToPromote = []): stdClass |
||
| 2400 | { |
||
| 2401 | $o = new stdClass(); |
||
| 2402 | $o->type = $type; |
||
| 2403 | $o->source_id = $sourceId; |
||
| 2404 | $o->destination_id = null; |
||
| 2405 | $o->has_obj = true; |
||
| 2406 | $o->obj = (object) $obj; |
||
| 2407 | |||
| 2408 | if (!isset($o->obj->iid)) { |
||
| 2409 | $o->obj->iid = $sourceId; |
||
| 2410 | } |
||
| 2411 | if (!isset($o->id)) { |
||
| 2412 | $o->id = $sourceId; |
||
| 2413 | } |
||
| 2414 | if (!isset($o->obj->id)) { |
||
| 2415 | $o->obj->id = $sourceId; |
||
| 2416 | } |
||
| 2417 | |||
| 2418 | // Promote scalars to top-level (like the builder) |
||
| 2419 | foreach ((array) $obj as $k => $v) { |
||
| 2420 | if (\is_scalar($v) || null === $v) { |
||
| 2421 | if (!property_exists($o, $k)) { |
||
| 2422 | $o->{$k} = $v; |
||
| 2423 | } |
||
| 2424 | } |
||
| 2425 | } |
||
| 2426 | // Promote array keys (e.g., items, linked_resources in learnpath) |
||
| 2427 | foreach ($arrayKeysToPromote as $k) { |
||
| 2428 | if (isset($obj[$k]) && \is_array($obj[$k])) { |
||
| 2429 | $o->{$k} = $obj[$k]; |
||
| 2430 | } |
||
| 2431 | } |
||
| 2432 | |||
| 2433 | // Special adjustments for documents |
||
| 2434 | if ('document' === $type) { |
||
| 2435 | $o->path = (string) ($o->path ?? $o->full_path ?? $o->obj->path ?? $o->obj->full_path ?? ''); |
||
| 2436 | $o->full_path = (string) ($o->full_path ?? $o->path ?? $o->obj->full_path ?? $o->obj->path ?? ''); |
||
| 2437 | $o->file_type = (string) ($o->file_type ?? $o->filetype ?? $o->obj->file_type ?? $o->obj->filetype ?? ''); |
||
| 2438 | $o->filetype = (string) ($o->filetype ?? $o->file_type ?? $o->obj->filetype ?? $o->obj->file_type ?? ''); |
||
| 2439 | $o->title = (string) ($o->title ?? $o->obj->title ?? ''); |
||
| 2440 | if (!isset($o->name) || '' === $o->name || null === $o->name) { |
||
| 2441 | $o->name = '' !== $o->title ? $o->title : ('document '.$sourceId); |
||
| 2442 | } |
||
| 2443 | } |
||
| 2444 | |||
| 2445 | // Default name if missing |
||
| 2446 | if (!isset($o->name) || '' === $o->name || null === $o->name) { |
||
| 2447 | if (isset($obj['name']) && '' !== $obj['name']) { |
||
| 2448 | $o->name = (string) $obj['name']; |
||
| 2449 | } elseif (isset($obj['title']) && '' !== $obj['title']) { |
||
| 2450 | $o->name = (string) $obj['title']; |
||
| 2451 | } else { |
||
| 2452 | $o->name = $type.' '.$sourceId; |
||
| 2453 | } |
||
| 2454 | } |
||
| 2455 | |||
| 2456 | return $o; |
||
| 2457 | } |
||
| 2458 | |||
| 2459 | /** |
||
| 2460 | * Replace Moodle @@PLUGINFILE@@ placeholders with package-local /document/ URLs |
||
| 2461 | * so that later HTML URL mapping can resolve and rewire them correctly. |
||
| 2462 | * Examples: |
||
| 2463 | * @@PLUGINFILE@@/Documents/foo.png -> /document/foo.png |
||
| 2464 | * @@PLUGINFILE@@/documents/bar.pdf -> /document/bar.pdf |
||
| 2465 | */ |
||
| 2466 | private function normalizePluginfileContent(string $html): string |
||
| 2467 | { |
||
| 2468 | if ('' === $html) { |
||
| 2469 | return $html; |
||
| 2470 | } |
||
| 2471 | |||
| 2472 | // Case-insensitive replace; keep a single leading slash |
||
| 2473 | // Handles both Documents/ and documents/ |
||
| 2474 | $html = preg_replace('~@@PLUGINFILE@@/(?:Documents|documents)/~i', '/document/', $html); |
||
| 2475 | |||
| 2476 | return $html; |
||
| 2477 | } |
||
| 2478 | |||
| 2479 | private function readCourseMeta(string $courseXmlPath): array |
||
| 2480 | { |
||
| 2481 | if (!is_file($courseXmlPath)) { |
||
| 2482 | return []; |
||
| 2483 | } |
||
| 2484 | $doc = $this->loadXml($courseXmlPath); |
||
| 2485 | $xp = new DOMXPath($doc); |
||
| 2486 | |||
| 2487 | $get = static function (string $q) use ($xp) { |
||
| 2488 | $n = $xp->query($q)->item(0); |
||
| 2489 | |||
| 2490 | return $n ? (string) $n->nodeValue : ''; |
||
| 2491 | }; |
||
| 2492 | |||
| 2493 | // Moodle course.xml typical nodes |
||
| 2494 | $fullname = $get('//course/fullname'); |
||
| 2495 | $shortname = $get('//course/shortname'); |
||
| 2496 | $idnumber = $get('//course/idnumber'); |
||
| 2497 | $summary = $get('//course/summary'); |
||
| 2498 | $format = $get('//course/format'); |
||
| 2499 | |||
| 2500 | $startdate = (int) ($get('//course/startdate') ?: 0); |
||
| 2501 | $enddate = (int) ($get('//course/enddate') ?: 0); |
||
| 2502 | |||
| 2503 | return [ |
||
| 2504 | 'fullname' => $fullname, |
||
| 2505 | 'shortname' => $shortname, |
||
| 2506 | 'idnumber' => $idnumber, |
||
| 2507 | 'summary' => $summary, |
||
| 2508 | 'format' => $format, |
||
| 2509 | 'startdate' => $startdate, |
||
| 2510 | 'enddate' => $enddate, |
||
| 2511 | ]; |
||
| 2512 | } |
||
| 2513 | |||
| 2514 | private function mergeMetaExportIfPresent(string $workDir, array $meta): array |
||
| 2515 | { |
||
| 2516 | $candidates = ['meta_export.json', 'export_meta.json', 'meta.json']; |
||
| 2517 | foreach ($candidates as $fn) { |
||
| 2518 | $p = rtrim($workDir,'/').'/'.$fn; |
||
| 2519 | if (is_file($p)) { |
||
| 2520 | $raw = @file_get_contents($p); |
||
| 2521 | if (false !== $raw && '' !== $raw) { |
||
| 2522 | $j = json_decode($raw, true); |
||
| 2523 | if (\is_array($j)) { |
||
| 2524 | // shallow merge under 'metaexport' |
||
| 2525 | $meta['metaexport'] = $j; |
||
| 2526 | } |
||
| 2527 | } |
||
| 2528 | break; |
||
| 2529 | } |
||
| 2530 | } |
||
| 2531 | |||
| 2532 | return $meta; |
||
| 2533 | } |
||
| 2534 | |||
| 2535 | private function readQuizModule(string $workDir, string $dir, string $quizXmlPath): array |
||
| 2536 | { |
||
| 2537 | $doc = $this->loadXml($quizXmlPath); |
||
| 2538 | $xp = new DOMXPath($doc); |
||
| 2539 | |||
| 2540 | $name = (string) ($xp->query('//quiz/name')->item(0)?->nodeValue ?? 'Quiz'); |
||
| 2541 | $intro = (string) ($xp->query('//quiz/intro')->item(0)?->nodeValue ?? ''); |
||
| 2542 | $timeopen = (int) ($xp->query('//quiz/timeopen')->item(0)?->nodeValue ?? 0); |
||
| 2543 | $timeclose = (int) ($xp->query('//quiz/timeclose')->item(0)?->nodeValue ?? 0); |
||
| 2544 | $timelimit = (int) ($xp->query('//quiz/timelimit')->item(0)?->nodeValue ?? 0); |
||
| 2545 | |||
| 2546 | $quiz = [ |
||
| 2547 | 'name' => $name, |
||
| 2548 | 'description' => $intro, |
||
| 2549 | 'timeopen' => $timeopen, |
||
| 2550 | 'timeclose' => $timeclose, |
||
| 2551 | 'timelimit' => $timelimit, |
||
| 2552 | 'attempts' => (int) ($xp->query('//quiz/attempts')->item(0)?->nodeValue ?? 0), |
||
| 2553 | 'shuffle' => (int) ($xp->query('//quiz/shufflequestions')->item(0)?->nodeValue ?? 0), |
||
| 2554 | ]; |
||
| 2555 | |||
| 2556 | // Question bank usually sits at $dir/questions.xml (varies by Moodle version) |
||
| 2557 | $qxml = $workDir.'/'.$dir.'/questions.xml'; |
||
| 2558 | $questions = []; |
||
| 2559 | if (is_file($qxml)) { |
||
| 2560 | $qDoc = $this->loadXml($qxml); |
||
| 2561 | $qx = new DOMXPath($qDoc); |
||
| 2562 | |||
| 2563 | foreach ($qx->query('//question') as $qn) { |
||
| 2564 | /** @var DOMElement $qn */ |
||
| 2565 | $qtype = strtolower((string) $qn->getAttribute('type')); |
||
| 2566 | $qname = (string) ($qn->getElementsByTagName('name')->item(0)?->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); |
||
| 2567 | $qtext = (string) ($qn->getElementsByTagName('questiontext')->item(0)?->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); |
||
| 2568 | |||
| 2569 | $q = [ |
||
| 2570 | 'type' => $qtype ?: 'description', |
||
| 2571 | 'name' => $qname ?: 'Question', |
||
| 2572 | 'questiontext' => $qtext, |
||
| 2573 | 'answers' => [], |
||
| 2574 | 'defaultgrade' => (float) ($qn->getElementsByTagName('defaultgrade')->item(0)?->nodeValue ?? 1.0), |
||
| 2575 | 'single' => null, |
||
| 2576 | 'correct' => [], |
||
| 2577 | ]; |
||
| 2578 | |||
| 2579 | if ('multichoice' === $qtype) { |
||
| 2580 | $single = (int) ($qn->getElementsByTagName('single')->item(0)?->nodeValue ?? 1); |
||
| 2581 | $q['single'] = $single; |
||
| 2582 | |||
| 2583 | foreach ($qn->getElementsByTagName('answer') as $an) { |
||
| 2584 | /** @var DOMElement $an */ |
||
| 2585 | $t = (string) ($an->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); |
||
| 2586 | $f = (float) ($an->getAttribute('fraction') ?: 0); |
||
| 2587 | $q['answers'][] = ['text' => $t, 'fraction' => $f]; |
||
| 2588 | if ($f > 0) { |
||
| 2589 | $q['correct'][] = $t; |
||
| 2590 | } |
||
| 2591 | } |
||
| 2592 | } elseif ('truefalse' === $qtype) { |
||
| 2593 | foreach ($qn->getElementsByTagName('answer') as $an) { |
||
| 2594 | $t = (string) ($an->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); |
||
| 2595 | $f = (float) ($an->getAttribute('fraction') ?: 0); |
||
| 2596 | $q['answers'][] = ['text' => $t, 'fraction' => $f]; |
||
| 2597 | if ($f > 0) { |
||
| 2598 | $q['correct'][] = $t; |
||
| 2599 | } |
||
| 2600 | } |
||
| 2601 | } // else: keep minimal info |
||
| 2602 | |||
| 2603 | $questions[] = $q; |
||
| 2604 | } |
||
| 2605 | } |
||
| 2606 | |||
| 2607 | return [$quiz, $questions]; |
||
| 2608 | } |
||
| 2609 | |||
| 2610 | private function readAssignModule(string $xmlPath): array |
||
| 2611 | { |
||
| 2612 | $doc = $this->loadXml($xmlPath); |
||
| 2613 | $xp = new DOMXPath($doc); |
||
| 2614 | |||
| 2615 | $name = (string) ($xp->query('//assign/name')->item(0)?->nodeValue ?? 'Assignment'); |
||
| 2616 | $intro = (string) ($xp->query('//assign/intro')->item(0)?->nodeValue ?? ''); |
||
| 2617 | $duedate = (int) ($xp->query('//assign/duedate')->item(0)?->nodeValue ?? 0); |
||
| 2618 | $allowsub = (int) ($xp->query('//assign/teamsubmission')->item(0)?->nodeValue ?? 0); |
||
| 2619 | |||
| 2620 | return [ |
||
| 2621 | 'name' => $name, |
||
| 2622 | 'description' => $intro, |
||
| 2623 | 'deadline' => $duedate, |
||
| 2624 | 'group' => $allowsub, |
||
| 2625 | ]; |
||
| 2626 | } |
||
| 2627 | |||
| 2628 | private function readSurveyModule(string $xmlPath, string $type): array |
||
| 2629 | { |
||
| 2630 | $doc = $this->loadXml($xmlPath); |
||
| 2631 | $xp = new DOMXPath($doc); |
||
| 2632 | |||
| 2633 | $name = (string) ($xp->query("//{$type}/name")->item(0)?->nodeValue ?? ucfirst($type)); |
||
| 2634 | $intro = (string) ($xp->query("//{$type}/intro")->item(0)?->nodeValue ?? ''); |
||
| 2635 | |||
| 2636 | return [ |
||
| 2637 | 'name' => $name, |
||
| 2638 | 'subtitle' => '', |
||
| 2639 | 'intro' => $intro, |
||
| 2640 | 'thanks' => '', |
||
| 2641 | 'survey_type' => $type, |
||
| 2642 | ]; |
||
| 2643 | } |
||
| 2644 | |||
| 2645 | private function readGlossaryModule(string $xmlPath): array |
||
| 2646 | { |
||
| 2647 | $doc = $this->loadXml($xmlPath); |
||
| 2648 | $xp = new DOMXPath($doc); |
||
| 2649 | |||
| 2650 | $name = (string) ($xp->query('//glossary/name')->item(0)?->nodeValue ?? 'Glossary'); |
||
| 2651 | $intro = (string) ($xp->query('//glossary/intro')->item(0)?->nodeValue ?? ''); |
||
| 2652 | |||
| 2653 | $entries = []; |
||
| 2654 | foreach ($xp->query('//glossary/entries/entry') as $eNode) { |
||
| 2655 | /** @var DOMElement $eNode */ |
||
| 2656 | $entryId = (int) $eNode->getAttribute('id'); |
||
| 2657 | $concept = trim((string) ($xp->evaluate('string(concept)', $eNode) ?? '')); |
||
| 2658 | $definition = (string) ($xp->evaluate('string(definition)', $eNode) ?? ''); |
||
| 2659 | $approved = (int) $xp->evaluate('number(approved)', $eNode); |
||
| 2660 | $userId = (int) $xp->evaluate('number(userid)', $eNode); |
||
| 2661 | $created = (int) $xp->evaluate('number(timecreated)', $eNode); |
||
| 2662 | $modified = (int) $xp->evaluate('number(timemodified)', $eNode); |
||
| 2663 | |||
| 2664 | // Collect aliases |
||
| 2665 | $aliases = []; |
||
| 2666 | foreach ($xp->query('aliases/alias/alias_text', $eNode) as $aNode) { |
||
| 2667 | $aliases[] = (string) $aNode->nodeValue; |
||
| 2668 | } |
||
| 2669 | |||
| 2670 | $entries[] = [ |
||
| 2671 | 'id' => $entryId, |
||
| 2672 | 'concept' => $concept, |
||
| 2673 | 'definition' => $definition, // keep HTML; resolver for @@PLUGINFILE@@ can run later |
||
| 2674 | 'approved' => $approved ?: 1, |
||
| 2675 | 'userid' => $userId, |
||
| 2676 | 'timecreated' => $created, |
||
| 2677 | 'timemodified'=> $modified, |
||
| 2678 | 'aliases' => $aliases, |
||
| 2679 | ]; |
||
| 2680 | } |
||
| 2681 | |||
| 2682 | return [ |
||
| 2683 | 'name' => $name, |
||
| 2684 | 'description' => $intro, |
||
| 2685 | 'entries' => $entries, |
||
| 2686 | ]; |
||
| 2687 | } |
||
| 2688 | |||
| 2689 | /** |
||
| 2690 | * Read course-level events from /course/calendar.xml (as written by CourseExport). |
||
| 2691 | * Returns legacy-shaped payloads for the 'events' bag. |
||
| 2692 | * |
||
| 2693 | * @return array<int,array<string,mixed>> |
||
| 2694 | */ |
||
| 2695 | private function readCourseEvents(string $workDir): array |
||
| 2696 | { |
||
| 2697 | $path = rtrim($workDir, '/').'/course/calendar.xml'; |
||
| 2698 | if (!is_file($path)) { |
||
| 2699 | // No calendar file -> no events |
||
| 2700 | return []; |
||
| 2701 | } |
||
| 2702 | |||
| 2703 | // Load XML safely |
||
| 2704 | $doc = new \DOMDocument('1.0', 'UTF-8'); |
||
| 2705 | $doc->preserveWhiteSpace = false; |
||
| 2706 | $doc->formatOutput = false; |
||
| 2707 | |||
| 2708 | $prev = libxml_use_internal_errors(true); |
||
| 2709 | $ok = @$doc->load($path); |
||
| 2710 | libxml_clear_errors(); |
||
| 2711 | libxml_use_internal_errors($prev); |
||
| 2712 | |||
| 2713 | if (!$ok) { |
||
| 2714 | // Corrupted calendar.xml -> ignore |
||
| 2715 | return []; |
||
| 2716 | } |
||
| 2717 | |||
| 2718 | $xp = new \DOMXPath($doc); |
||
| 2719 | $evNodes = $xp->query('/calendar/event'); |
||
| 2720 | if (!$evNodes || $evNodes->length === 0) { |
||
| 2721 | return []; |
||
| 2722 | } |
||
| 2723 | |||
| 2724 | $out = []; |
||
| 2725 | /** @var \DOMElement $ev */ |
||
| 2726 | foreach ($evNodes as $ev) { |
||
| 2727 | $get = static function (\DOMElement $ctx, string $tag): ?string { |
||
| 2728 | $n = $ctx->getElementsByTagName($tag)->item(0); |
||
| 2729 | return $n ? (string) $n->nodeValue : null; // preserves CDATA inner content |
||
| 2730 | }; |
||
| 2731 | |||
| 2732 | // Fields per CourseExport::createCalendarXml() |
||
| 2733 | $name = trim((string) ($get($ev, 'name') ?? '')); |
||
| 2734 | $desc = (string) ($get($ev, 'description') ?? ''); |
||
| 2735 | $timestartV = (string) ($get($ev, 'timestart') ?? ''); |
||
| 2736 | $durationV = (string) ($get($ev, 'duration') ?? ''); |
||
| 2737 | $alldayV = (string) ($get($ev, 'allday') ?? ''); |
||
| 2738 | $visibleV = (string) ($get($ev, 'visible') ?? '1'); |
||
| 2739 | $eventtype = (string) ($get($ev, 'eventtype') ?? 'course'); |
||
| 2740 | $uuid = (string) ($get($ev, 'uuid') ?? ''); |
||
| 2741 | |||
| 2742 | // Tolerant parsing: accept numeric or date-string (older/other writers) |
||
| 2743 | $toTs = static function (string $v, int $fallback = 0): int { |
||
| 2744 | if ($v === '') { return $fallback; } |
||
| 2745 | if (is_numeric($v)) { return (int) $v; } |
||
| 2746 | $t = @\strtotime($v); |
||
| 2747 | return $t !== false ? (int) $t : $fallback; |
||
| 2748 | }; |
||
| 2749 | |||
| 2750 | $timestart = $toTs($timestartV, time()); |
||
| 2751 | $duration = max(0, (int) $durationV); |
||
| 2752 | $allday = (int) $alldayV ? 1 : 0; |
||
| 2753 | $visible = (int) $visibleV ? 1 : 0; |
||
| 2754 | |||
| 2755 | // Legacy-friendly payload (used by mkLegacyItem('events', ...)) |
||
| 2756 | $payload = [ |
||
| 2757 | 'name' => $name !== '' ? $name : 'Event', |
||
| 2758 | 'description' => $desc, // HTML allowed (comes from CDATA) |
||
| 2759 | 'timestart' => $timestart, // Unix timestamp |
||
| 2760 | 'duration' => $duration, // seconds |
||
| 2761 | 'allday' => $allday, // 0/1 |
||
| 2762 | 'visible' => $visible, // 0/1 |
||
| 2763 | 'eventtype' => $eventtype, // 'course' by default |
||
| 2764 | 'uuid' => $uuid, // $@NULL@$ or value |
||
| 2765 | ]; |
||
| 2766 | |||
| 2767 | $out[] = $payload; |
||
| 2768 | } |
||
| 2769 | |||
| 2770 | return $out; |
||
| 2771 | } |
||
| 2772 | |||
| 2773 | /** |
||
| 2774 | * Restore selected buckets using the generic CourseRestorer to persist them. |
||
| 2775 | * This lets us reuse the legacy snapshot you already build. |
||
| 2776 | */ |
||
| 2777 | private function restoreWithRestorer( |
||
| 2778 | string $archivePath, |
||
| 2779 | EntityManagerInterface $em, |
||
| 2780 | int $courseRealId, |
||
| 2781 | int $sessionId, |
||
| 2782 | array $allowedBuckets, // ej: ['quizzes','quiz_question'] |
||
| 2783 | ?object $courseArg = null |
||
| 2784 | ): array { |
||
| 2785 | $legacy = $courseArg ?: $this->buildLegacyCourseFromMoodleArchive($archivePath); |
||
| 2786 | $legacy->resources = isset($legacy->resources) && \is_array($legacy->resources) |
||
| 2787 | ? $this->canonicalizeResourceBags($legacy->resources) |
||
| 2788 | : []; |
||
| 2789 | |||
| 2790 | $expanded = $this->expandBucketAliases($allowedBuckets); |
||
| 2791 | $legacy->resources = array_intersect_key( |
||
| 2792 | (array) $legacy->resources, |
||
| 2793 | array_flip($expanded) |
||
| 2794 | ); |
||
| 2795 | |||
| 2796 | $total = 0; |
||
| 2797 | foreach ($legacy->resources as $v) { |
||
| 2798 | $total += \is_array($v) ? \count($v) : 0; |
||
| 2799 | } |
||
| 2800 | if ($total === 0) { |
||
| 2801 | return ['imported' => 0, 'notes' => ['No resources to restore for '.implode(',', $allowedBuckets)]]; |
||
| 2802 | } |
||
| 2803 | |||
| 2804 | $restorerClass = '\\Chamilo\\CourseBundle\\Component\\CourseCopy\\CourseRestorer'; |
||
| 2805 | if (!\class_exists($restorerClass)) { |
||
| 2806 | return ['imported' => 0, 'notes' => ['CourseRestorer not available']]; |
||
| 2807 | } |
||
| 2808 | $restorer = new $restorerClass($em, $courseRealId, $sessionId); |
||
| 2809 | |||
| 2810 | if (property_exists($restorer, 'course')) { |
||
| 2811 | $restorer->course = $legacy; |
||
| 2812 | } elseif (\method_exists($restorer, 'setCourse')) { |
||
| 2813 | $restorer->setCourse($legacy); |
||
| 2814 | } |
||
| 2815 | |||
| 2816 | $destCode = ''; |
||
| 2817 | $courseEntity = \function_exists('api_get_course_entity') ? api_get_course_entity($courseRealId) : null; |
||
| 2818 | if ($courseEntity && \method_exists($courseEntity, 'getCode')) { |
||
| 2819 | $destCode = (string) $courseEntity->getCode(); |
||
| 2820 | } else { |
||
| 2821 | $ci = \function_exists('api_get_course_info_by_id') ? api_get_course_info_by_id($courseRealId) : null; |
||
| 2822 | if (\is_array($ci) && !empty($ci['code'])) { |
||
| 2823 | $destCode = (string) $ci['code']; |
||
| 2824 | } |
||
| 2825 | } |
||
| 2826 | |||
| 2827 | if (\method_exists($restorer, 'restore')) { |
||
| 2828 | $restorer->restore($destCode, $sessionId, false, false); |
||
| 2829 | } else { |
||
| 2830 | return ['imported' => 0, 'notes' => ['No supported restore() method in CourseRestorer']]; |
||
| 2831 | } |
||
| 2832 | |||
| 2833 | return ['imported' => $total, 'notes' => ['Restored: '.implode(',', $expanded)]]; |
||
| 2834 | } |
||
| 2835 | |||
| 2836 | private function expandBucketAliases(array $buckets): array |
||
| 2837 | { |
||
| 2838 | $map = [ |
||
| 2839 | 'quizzes' => ['quizzes', 'quiz', \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : 'quiz'], |
||
| 2840 | 'quiz' => ['quiz', 'quizzes', \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : 'quiz'], |
||
| 2841 | 'quiz_question' => ['quiz_question', 'Exercise_Question', \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : 'quiz_question'], |
||
| 2842 | 'scorm' => ['scorm', 'scorm_documents'], |
||
| 2843 | 'scorm_documents' => ['scorm_documents', 'scorm'], |
||
| 2844 | 'document' => ['document', 'Document'], |
||
| 2845 | 'forum' => ['forum'], |
||
| 2846 | 'Forum_Category' => ['Forum_Category'], |
||
| 2847 | 'link' => ['link'], |
||
| 2848 | 'Link_Category' => ['Link_Category'], |
||
| 2849 | 'learnpath' => ['learnpath'], |
||
| 2850 | 'learnpath_category' => ['learnpath_category'], |
||
| 2851 | 'thematic' => ['thematic', \defined('RESOURCE_THEMATIC') ? (string) RESOURCE_THEMATIC : 'thematic'], |
||
| 2852 | 'attendance' => ['attendance', \defined('RESOURCE_ATTENDANCE') ? (string) RESOURCE_ATTENDANCE : 'attendance'], |
||
| 2853 | 'gradebook' => ['gradebook', 'Gradebook', \defined('RESOURCE_GRADEBOOK') ? (string) RESOURCE_GRADEBOOK : 'gradebook'], |
||
| 2854 | 'announcement' => array_values(array_unique(array_filter([ |
||
| 2855 | 'announcement', |
||
| 2856 | 'news', |
||
| 2857 | \defined('RESOURCE_ANNOUNCEMENT') ? (string) RESOURCE_ANNOUNCEMENT : null, |
||
| 2858 | ]))), |
||
| 2859 | 'news' => ['news', 'announcement'], |
||
| 2860 | ]; |
||
| 2861 | |||
| 2862 | $out = []; |
||
| 2863 | foreach ($buckets as $b) { |
||
| 2864 | $b = (string) $b; |
||
| 2865 | $out = array_merge($out, $map[$b] ?? [$b]); |
||
| 2866 | } |
||
| 2867 | |||
| 2868 | return array_values(array_unique($out)); |
||
| 2869 | } |
||
| 2870 | |||
| 2871 | /** |
||
| 2872 | * Convenience: restore a set of specific buckets using the generic CourseRestorer. |
||
| 2873 | * Keeps all logic inside MoodleImport (no new classes). |
||
| 2874 | * |
||
| 2875 | * @param string[] $buckets |
||
| 2876 | * @return array{imported:int,notes:array} |
||
| 2877 | */ |
||
| 2878 | public function restoreSelectedBuckets( |
||
| 2879 | string $archivePath, |
||
| 2880 | EntityManagerInterface $em, |
||
| 2881 | int $courseRealId, |
||
| 2882 | int $sessionId = 0, |
||
| 2883 | array $buckets = [], |
||
| 2884 | ?object $courseArg = null |
||
| 2885 | ): array { |
||
| 2886 | if (empty($buckets)) { |
||
| 2887 | return ['imported' => 0, 'notes' => ['No buckets requested']]; |
||
| 2888 | } |
||
| 2889 | |||
| 2890 | // Delegate to the generic restorer while filtering the snapshot to the requested buckets. |
||
| 2891 | return $this->restoreWithRestorer( |
||
| 2892 | $archivePath, |
||
| 2893 | $em, |
||
| 2894 | $courseRealId, |
||
| 2895 | $sessionId, |
||
| 2896 | $buckets, |
||
| 2897 | $courseArg |
||
| 2898 | ); |
||
| 2899 | } |
||
| 2900 | |||
| 2901 | /** Quizzes (+ question bank minimal snapshot) */ |
||
| 2902 | public function restoreQuizzes( |
||
| 2903 | string $archivePath, |
||
| 2904 | EntityManagerInterface $em, |
||
| 2905 | int $courseRealId, |
||
| 2906 | int $sessionId = 0, |
||
| 2907 | ?object $courseArg = null |
||
| 2908 | ): array { |
||
| 2909 | return $this->restoreSelectedBuckets( |
||
| 2910 | $archivePath, $em, $courseRealId, $sessionId, |
||
| 2911 | ['quizzes', 'quiz_question'], |
||
| 2912 | $courseArg |
||
| 2913 | ); |
||
| 2914 | } |
||
| 2915 | |||
| 2916 | /** SCORM packages */ |
||
| 2917 | public function restoreScorm( |
||
| 2918 | string $archivePath, |
||
| 2919 | EntityManagerInterface $em, |
||
| 2920 | int $courseRealId, |
||
| 2921 | int $sessionId = 0, |
||
| 2922 | ?object $courseArg = null |
||
| 2923 | ): array { |
||
| 2924 | // Keep both keys for maximum compatibility with CourseRestorer implementations |
||
| 2925 | return $this->restoreSelectedBuckets( |
||
| 2926 | $archivePath, $em, $courseRealId, $sessionId, |
||
| 2927 | ['scorm_documents', 'scorm'], |
||
| 2928 | $courseArg |
||
| 2929 | ); |
||
| 2930 | } |
||
| 2931 | |||
| 2932 | /** |
||
| 2933 | * Preferred Learnpath importer using Chamilo sidecar JSON under chamilo/learnpath. |
||
| 2934 | * Returns true if LPs (and categories) were imported from meta. |
||
| 2935 | */ |
||
| 2936 | private function tryImportLearnpathMeta(string $workDir, array &$resources): bool |
||
| 2937 | { |
||
| 2938 | $base = rtrim($workDir, '/').'/chamilo/learnpath'; |
||
| 2939 | $indexFile = $base.'/index.json'; |
||
| 2940 | if (!is_file($indexFile)) { |
||
| 2941 | return false; // No meta present -> fallback to sections |
||
| 2942 | } |
||
| 2943 | |||
| 2944 | $index = $this->readJsonFile($indexFile); |
||
| 2945 | $cats = $this->readJsonFile($base.'/categories.json'); |
||
| 2946 | |||
| 2947 | // 1) Ensure learnpath_category from meta (idempotent) |
||
| 2948 | $existingCatIds = array_map('intval', array_keys((array) ($resources['learnpath_category'] ?? []))); |
||
| 2949 | foreach ((array) ($cats['categories'] ?? []) as $c) { |
||
| 2950 | $cid = (int) ($c['id'] ?? 0); |
||
| 2951 | $title = (string) ($c['title'] ?? ''); |
||
| 2952 | if ($cid <= 0) { continue; } |
||
| 2953 | if (!\in_array($cid, $existingCatIds, true)) { |
||
| 2954 | // Preserve category id from meta to simplify mapping |
||
| 2955 | $resources['learnpath_category'][$cid] = $this->mkLegacyItem('learnpath_category', $cid, [ |
||
| 2956 | 'id' => $cid, |
||
| 2957 | 'name' => $title, |
||
| 2958 | 'title' => $title, |
||
| 2959 | ]); |
||
| 2960 | $existingCatIds[] = $cid; |
||
| 2961 | } |
||
| 2962 | } |
||
| 2963 | |||
| 2964 | // 2) Build search indexes to resolve item "ref" into our freshly-built resource IDs |
||
| 2965 | $idx = $this->buildResourceIndexes($resources); |
||
| 2966 | |||
| 2967 | // 3) Import learnpaths |
||
| 2968 | $imported = 0; |
||
| 2969 | foreach ((array) ($index['learnpaths'] ?? []) as $row) { |
||
| 2970 | $dir = (string) ($row['dir'] ?? ''); |
||
| 2971 | if ($dir === '') { continue; } |
||
| 2972 | |||
| 2973 | $lpJson = $this->readJsonFile($base.'/'.$dir.'/learnpath.json'); |
||
| 2974 | $itemsJson= $this->readJsonFile($base.'/'.$dir.'/items.json'); |
||
| 2975 | |||
| 2976 | $lpRaw = (array) ($lpJson['learnpath'] ?? []); |
||
| 2977 | // Defensive normalization |
||
| 2978 | $lpTitle = (string) ($lpRaw['title'] ?? $lpRaw['name'] ?? ($row['title'] ?? 'Lesson')); |
||
| 2979 | $lpType = (int) ($lpRaw['lp_type'] ?? $row['lp_type'] ?? 1); |
||
| 2980 | $catId = (int) ($lpRaw['category_id'] ?? $row['category_id'] ?? 0); |
||
| 2981 | |||
| 2982 | // Allocate a fresh ID (avoid collisions) but keep source id in meta |
||
| 2983 | $lid = $this->nextId($resources['learnpath']); |
||
| 2984 | $payload = [ |
||
| 2985 | 'id' => $lid, |
||
| 2986 | 'lp_type' => $lpType, // 1=LP, 2=SCORM, 3=AICC |
||
| 2987 | 'title' => $lpTitle, |
||
| 2988 | 'path' => (string) ($lpRaw['path'] ?? ''), |
||
| 2989 | 'ref' => (string) ($lpRaw['ref'] ?? ''), |
||
| 2990 | 'description' => (string) ($lpRaw['description'] ?? ''), |
||
| 2991 | 'content_local' => (string) ($lpRaw['content_local'] ?? ''), |
||
| 2992 | 'default_encoding' => (string) ($lpRaw['default_encoding'] ?? ''), |
||
| 2993 | 'default_view_mod' => (string) ($lpRaw['default_view_mod'] ?? ''), |
||
| 2994 | 'prevent_reinit' => (bool) ($lpRaw['prevent_reinit'] ?? false), |
||
| 2995 | 'force_commit' => (bool) ($lpRaw['force_commit'] ?? false), |
||
| 2996 | 'content_maker' => (string) ($lpRaw['content_maker'] ?? ''), |
||
| 2997 | 'display_order' => (int) ($lpRaw['display_order'] ?? 0), |
||
| 2998 | 'js_lib' => (string) ($lpRaw['js_lib'] ?? ''), |
||
| 2999 | 'content_license' => (string) ($lpRaw['content_license'] ?? ''), |
||
| 3000 | 'debug' => (bool) ($lpRaw['debug'] ?? false), |
||
| 3001 | 'visibility' => (string) ($lpRaw['visibility'] ?? '1'), |
||
| 3002 | 'author' => (string) ($lpRaw['author'] ?? ''), |
||
| 3003 | 'use_max_score' => (int) ($lpRaw['use_max_score'] ?? 0), |
||
| 3004 | 'autolaunch' => (int) ($lpRaw['autolaunch'] ?? 0), |
||
| 3005 | 'created_on' => (string) ($lpRaw['created_on'] ?? ''), |
||
| 3006 | 'modified_on' => (string) ($lpRaw['modified_on'] ?? ''), |
||
| 3007 | 'published_on' => (string) ($lpRaw['published_on'] ?? ''), |
||
| 3008 | 'expired_on' => (string) ($lpRaw['expired_on'] ?? ''), |
||
| 3009 | 'session_id' => 0, |
||
| 3010 | 'category_id' => $catId > 0 ? $catId : (array_key_first($resources['learnpath_category']) ?? 0), |
||
| 3011 | '_src' => [ |
||
| 3012 | 'lp_id' => (int) ($lpRaw['id'] ?? ($row['id'] ?? 0)), |
||
| 3013 | ], |
||
| 3014 | ]; |
||
| 3015 | |||
| 3016 | // Create wrapper with extended props (items, linked_resources) |
||
| 3017 | $resources['learnpath'][$lid] = $this->mkLegacyItem('learnpath', $lid, $payload, ['items','linked_resources']); |
||
| 3018 | |||
| 3019 | // Items: stable-order by display_order if present |
||
| 3020 | $rawItems = (array) ($itemsJson['items'] ?? []); |
||
| 3021 | usort($rawItems, static fn(array $a, array $b) => |
||
| 3022 | (int)($a['display_order'] ?? 0) <=> (int)($b['display_order'] ?? 0)); |
||
| 3023 | |||
| 3024 | $items = []; |
||
| 3025 | foreach ($rawItems as $it) { |
||
| 3026 | $mappedRef = $this->mapLpItemRef($it, $idx, $resources); |
||
| 3027 | $items[] = [ |
||
| 3028 | 'id' => (int) ($it['id'] ?? 0), |
||
| 3029 | 'item_type' => (string)($it['item_type'] ?? ''), |
||
| 3030 | 'ref' => $mappedRef, |
||
| 3031 | 'title' => (string)($it['title'] ?? ''), |
||
| 3032 | 'name' => (string)($it['name'] ?? $lpTitle), |
||
| 3033 | 'description' => (string)($it['description'] ?? ''), |
||
| 3034 | 'path' => (string)($it['path'] ?? ''), |
||
| 3035 | 'min_score' => (float) ($it['min_score'] ?? 0), |
||
| 3036 | 'max_score' => isset($it['max_score']) ? (float) $it['max_score'] : null, |
||
| 3037 | 'mastery_score' => isset($it['mastery_score']) ? (float) $it['mastery_score'] : null, |
||
| 3038 | 'parent_item_id' => (int) ($it['parent_item_id'] ?? 0), |
||
| 3039 | 'previous_item_id'=> isset($it['previous_item_id']) ? (int) $it['previous_item_id'] : null, |
||
| 3040 | 'next_item_id' => isset($it['next_item_id']) ? (int) $it['next_item_id'] : null, |
||
| 3041 | 'display_order' => (int) ($it['display_order'] ?? 0), |
||
| 3042 | 'prerequisite' => (string)($it['prerequisite'] ?? ''), |
||
| 3043 | 'parameters' => (string)($it['parameters'] ?? ''), |
||
| 3044 | 'launch_data' => (string)($it['launch_data'] ?? ''), |
||
| 3045 | 'audio' => (string)($it['audio'] ?? ''), |
||
| 3046 | '_src' => [ |
||
| 3047 | 'ref' => $it['ref'] ?? null, |
||
| 3048 | 'path' => $it['path'] ?? null, |
||
| 3049 | ], |
||
| 3050 | ]; |
||
| 3051 | } |
||
| 3052 | |||
| 3053 | $resources['learnpath'][$lid]->items = $items; |
||
| 3054 | $resources['learnpath'][$lid]->linked_resources = $this->collectLinkedFromLpItems($items); |
||
| 3055 | |||
| 3056 | $imported++; |
||
| 3057 | } |
||
| 3058 | |||
| 3059 | if ($this->debug) { |
||
| 3060 | @error_log("MOODLE_IMPORT: LPs from meta imported={$imported}"); |
||
| 3061 | } |
||
| 3062 | return $imported > 0; |
||
| 3063 | } |
||
| 3064 | |||
| 3065 | /** Read JSON file safely; return [] on error. */ |
||
| 3066 | private function readJsonFile(string $file): array |
||
| 3067 | { |
||
| 3068 | $raw = @file_get_contents($file); |
||
| 3069 | if ($raw === false) { return []; } |
||
| 3070 | $data = json_decode($raw, true); |
||
| 3071 | return \is_array($data) ? $data : []; |
||
| 3072 | } |
||
| 3073 | |||
| 3074 | /** |
||
| 3075 | * Build look-up indexes over the freshly collected resources to resolve LP item refs. |
||
| 3076 | * We index by multiple keys to increase match odds (path, title, url, etc.) |
||
| 3077 | */ |
||
| 3078 | private function buildResourceIndexes(array $resources): array |
||
| 3079 | { |
||
| 3080 | $idx = [ |
||
| 3081 | 'documentByPath' => [], |
||
| 3082 | 'documentByTitle' => [], |
||
| 3083 | 'linkByUrl' => [], |
||
| 3084 | 'forumByTitle' => [], |
||
| 3085 | 'quizByTitle' => [], |
||
| 3086 | 'workByTitle' => [], |
||
| 3087 | 'scormByTitle' => [], |
||
| 3088 | ]; |
||
| 3089 | |||
| 3090 | foreach ((array) ($resources['document'] ?? []) as $id => $doc) { |
||
| 3091 | $arr = \is_object($doc) ? get_object_vars($doc) : (array) $doc; |
||
| 3092 | $p = (string) ($arr['path'] ?? ''); |
||
| 3093 | $t = (string) ($arr['title'] ?? ''); |
||
| 3094 | if ($p !== '') { $idx['documentByPath'][$p] = (int) $id; } |
||
| 3095 | if ($t !== '') { $idx['documentByTitle'][mb_strtolower($t)][] = (int) $id; } |
||
| 3096 | } |
||
| 3097 | foreach ((array) ($resources['link'] ?? []) as $id => $lnk) { |
||
| 3098 | $arr = \is_object($lnk) ? get_object_vars($lnk) : (array) $lnk; |
||
| 3099 | $u = (string) ($arr['url'] ?? ''); |
||
| 3100 | if ($u !== '') { $idx['linkByUrl'][$u] = (int) $id; } |
||
| 3101 | } |
||
| 3102 | foreach ((array) ($resources['forum'] ?? []) as $id => $f) { |
||
| 3103 | $arr = \is_object($f) ? get_object_vars($f) : (array) $f; |
||
| 3104 | $t = (string) ($arr['forum_title'] ?? $arr['title'] ?? ''); |
||
| 3105 | if ($t !== '') { $idx['forumByTitle'][mb_strtolower($t)][] = (int) $id; } |
||
| 3106 | } |
||
| 3107 | foreach ((array) ($resources['quizzes'] ?? []) as $id => $q) { |
||
| 3108 | $arr = \is_object($q) ? get_object_vars($q) : (array) $q; |
||
| 3109 | $t = (string) ($arr['name'] ?? $arr['title'] ?? ''); |
||
| 3110 | if ($t !== '') { $idx['quizByTitle'][mb_strtolower($t)][] = (int) $id; } |
||
| 3111 | } |
||
| 3112 | foreach ((array) ($resources['works'] ?? []) as $id => $w) { |
||
| 3113 | $arr = \is_object($w) ? get_object_vars($w) : (array) $w; |
||
| 3114 | $t = (string) ($arr['name'] ?? $arr['title'] ?? ''); |
||
| 3115 | if ($t !== '') { $idx['workByTitle'][mb_strtolower($t)][] = (int) $id; } |
||
| 3116 | } |
||
| 3117 | foreach ((array) ($resources['scorm'] ?? $resources['scorm_documents'] ?? []) as $id => $s) { |
||
| 3118 | $arr = \is_object($s) ? get_object_vars($s) : (array) $s; |
||
| 3119 | $t = (string) ($arr['title'] ?? $arr['name'] ?? ''); |
||
| 3120 | if ($t !== '') { $idx['scormByTitle'][mb_strtolower($t)][] = (int) $id; } |
||
| 3121 | } |
||
| 3122 | |||
| 3123 | return $idx; |
||
| 3124 | } |
||
| 3125 | |||
| 3126 | /** |
||
| 3127 | * Resolve LP item "ref" from meta (which refers to the source system) into a local resource id. |
||
| 3128 | * Strategy: |
||
| 3129 | * 1) For documents: match by path (strong), or by title (weak). |
||
| 3130 | * 2) For links: match by url. |
||
| 3131 | * 3) For forum/quizzes/works/scorm: match by title. |
||
| 3132 | * If not resolvable, return null and keep original _src in item for later diagnostics. |
||
| 3133 | */ |
||
| 3134 | private function mapLpItemRef(array $item, array $idx, array $resources): ?int |
||
| 3135 | { |
||
| 3136 | $type = (string) ($item['item_type'] ?? ''); |
||
| 3137 | $srcRef = $item['ref'] ?? null; |
||
| 3138 | $path = (string) ($item['path'] ?? ''); |
||
| 3139 | $title = mb_strtolower((string) ($item['title'] ?? '')); |
||
| 3140 | |||
| 3141 | switch ($type) { |
||
| 3142 | case 'document': |
||
| 3143 | if ($path !== '' && isset($idx['documentByPath'][$path])) { |
||
| 3144 | return $idx['documentByPath'][$path]; |
||
| 3145 | } |
||
| 3146 | if ($title !== '' && !empty($idx['documentByTitle'][$title])) { |
||
| 3147 | // If multiple, pick the first; could be improved with size/hash if available |
||
| 3148 | return $idx['documentByTitle'][$title][0]; |
||
| 3149 | } |
||
| 3150 | return null; |
||
| 3151 | |||
| 3152 | case 'link': |
||
| 3153 | if (isset($idx['linkByUrl'][$srcRef])) { |
||
| 3154 | return $idx['linkByUrl'][$srcRef]; |
||
| 3155 | } |
||
| 3156 | // Sometimes meta keeps URL in "path" |
||
| 3157 | if ($path !== '' && isset($idx['linkByUrl'][$path])) { |
||
| 3158 | return $idx['linkByUrl'][$path]; |
||
| 3159 | } |
||
| 3160 | return null; |
||
| 3161 | |||
| 3162 | case 'forum': |
||
| 3163 | if ($title !== '' && !empty($idx['forumByTitle'][$title])) { |
||
| 3164 | return $idx['forumByTitle'][$title][0]; |
||
| 3165 | } |
||
| 3166 | return null; |
||
| 3167 | |||
| 3168 | case 'quiz': |
||
| 3169 | case 'quizzes': |
||
| 3170 | if ($title !== '' && !empty($idx['quizByTitle'][$title])) { |
||
| 3171 | return $idx['quizByTitle'][$title][0]; |
||
| 3172 | } |
||
| 3173 | return null; |
||
| 3174 | |||
| 3175 | case 'works': |
||
| 3176 | if ($title !== '' && !empty($idx['workByTitle'][$title])) { |
||
| 3177 | return $idx['workByTitle'][$title][0]; |
||
| 3178 | } |
||
| 3179 | return null; |
||
| 3180 | |||
| 3181 | case 'scorm': |
||
| 3182 | if ($title !== '' && !empty($idx['scormByTitle'][$title])) { |
||
| 3183 | return $idx['scormByTitle'][$title][0]; |
||
| 3184 | } |
||
| 3185 | return null; |
||
| 3186 | |||
| 3187 | default: |
||
| 3188 | return null; |
||
| 3189 | } |
||
| 3190 | } |
||
| 3191 | |||
| 3192 | /** |
||
| 3193 | " Import quizzes from QuizMetaExport sidecars under chamilo/quiz/quiz_. |
||
| 3194 | * Builds 'quiz' and 'quiz_question' (and their constant-key aliases if defined). |
||
| 3195 | * Returns true if at least one quiz was imported. |
||
| 3196 | */ |
||
| 3197 | private function tryImportQuizMeta(string $workDir, array &$resources): bool |
||
| 3198 | { |
||
| 3199 | $base = rtrim($workDir, '/').'/chamilo/quiz'; |
||
| 3200 | if (!is_dir($base)) { |
||
| 3201 | return false; |
||
| 3202 | } |
||
| 3203 | |||
| 3204 | // Resolve resource keys (support both constant and string bags) |
||
| 3205 | $quizKey = \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz'; |
||
| 3206 | // Chamilo snapshot also uses sometimes 'quizzes' — we fill both for compatibility |
||
| 3207 | $quizCompatKey = 'quizzes'; |
||
| 3208 | |||
| 3209 | $qqKey = \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : 'Exercise_Question'; |
||
| 3210 | $qqCompatKey = 'quiz_question'; |
||
| 3211 | |||
| 3212 | $imported = 0; |
||
| 3213 | |||
| 3214 | // Iterate all quiz_* folders |
||
| 3215 | $dh = @opendir($base); |
||
| 3216 | if (!$dh) { |
||
| 3217 | return false; |
||
| 3218 | } |
||
| 3219 | |||
| 3220 | while (false !== ($entry = readdir($dh))) { |
||
| 3221 | if ($entry === '.' || $entry === '..') { |
||
| 3222 | continue; |
||
| 3223 | } |
||
| 3224 | $dir = $base.'/'.$entry; |
||
| 3225 | if (!is_dir($dir) || strpos($entry, 'quiz_') !== 0) { |
||
| 3226 | continue; |
||
| 3227 | } |
||
| 3228 | |||
| 3229 | $quizJsonFile = $dir.'/quiz.json'; |
||
| 3230 | $questionsFile = $dir.'/questions.json'; |
||
| 3231 | $answersFile = $dir.'/answers.json'; // optional (flat; we prefer nested) |
||
| 3232 | |||
| 3233 | $quizWrap = $this->readJsonFile($quizJsonFile); |
||
| 3234 | $qList = $this->readJsonFile($questionsFile); |
||
| 3235 | if (empty($quizWrap) || empty($qList)) { |
||
| 3236 | // Nothing to import for this folder |
||
| 3237 | if ($this->debug) { |
||
| 3238 | @error_log("MOODLE_IMPORT: Quiz meta missing or incomplete in {$entry}"); |
||
| 3239 | } |
||
| 3240 | continue; |
||
| 3241 | } |
||
| 3242 | |||
| 3243 | $quizArr = (array) ($quizWrap['quiz'] ?? []); |
||
| 3244 | $questions = (array) ($qList['questions'] ?? []); |
||
| 3245 | |||
| 3246 | // ---- Resolve or allocate quiz local id |
||
| 3247 | $title = (string) ($quizArr['title'] ?? $quizArr['name'] ?? 'Quiz'); |
||
| 3248 | $qidLocal = $this->findExistingQuizIdByTitle($resources, $title, [$quizKey, $quizCompatKey]); |
||
| 3249 | if (!$qidLocal) { |
||
| 3250 | $qidLocal = $this->nextId($resources[$quizKey] ?? []); |
||
| 3251 | } |
||
| 3252 | |||
| 3253 | // Ensure bags exist |
||
| 3254 | if (!isset($resources[$quizKey])) { $resources[$quizKey] = []; } |
||
| 3255 | if (!isset($resources[$quizCompatKey])){ $resources[$quizCompatKey] = []; } |
||
| 3256 | if (!isset($resources[$qqKey])) { $resources[$qqKey] = []; } |
||
| 3257 | if (!isset($resources[$qqCompatKey])) { $resources[$qqCompatKey] = []; } |
||
| 3258 | |||
| 3259 | // ---- Build local question id map (src → local) |
||
| 3260 | $srcToLocalQ = []; |
||
| 3261 | // If meta provides question_ids, we keep order from 'question_orders' when present. |
||
| 3262 | $srcIds = array_map('intval', (array) ($quizArr['question_ids'] ?? [])); |
||
| 3263 | $srcOrder = array_map('intval', (array) ($quizArr['question_orders'] ?? [])); |
||
| 3264 | |||
| 3265 | // First pass: assign local ids to all questions we are about to import |
||
| 3266 | foreach ($questions as $qArr) { |
||
| 3267 | // Prefer explicit id added by exporter; otherwise try _links.quiz_id or fallback 0 |
||
| 3268 | $srcQid = (int) ($qArr['id'] ?? 0); |
||
| 3269 | if ($srcQid <= 0) { |
||
| 3270 | // Try to infer from position in the list (not ideal, but keeps import going) |
||
| 3271 | $srcQid = $this->nextId($srcToLocalQ); // synthetic progressive id |
||
| 3272 | } |
||
| 3273 | $srcToLocalQ[$srcQid] = $this->nextId($resources[$qqKey]); |
||
| 3274 | } |
||
| 3275 | |||
| 3276 | // ---- Rebuild quiz payload (builder-compatible) |
||
| 3277 | $payload = [ |
||
| 3278 | 'title' => (string) ($quizArr['title'] ?? ''), |
||
| 3279 | 'description' => (string) ($quizArr['description'] ?? ''), |
||
| 3280 | 'type' => (int) ($quizArr['type'] ?? 0), |
||
| 3281 | 'random' => (int) ($quizArr['random'] ?? 0), |
||
| 3282 | 'random_answers' => (bool) ($quizArr['random_answers'] ?? false), |
||
| 3283 | 'results_disabled' => (int) ($quizArr['results_disabled'] ?? 0), |
||
| 3284 | 'max_attempt' => (int) ($quizArr['max_attempt'] ?? 0), |
||
| 3285 | 'feedback_type' => (int) ($quizArr['feedback_type'] ?? 0), |
||
| 3286 | 'expired_time' => (int) ($quizArr['expired_time'] ?? 0), |
||
| 3287 | 'review_answers' => (int) ($quizArr['review_answers'] ?? 0), |
||
| 3288 | 'random_by_category' => (int) ($quizArr['random_by_category'] ?? 0), |
||
| 3289 | 'text_when_finished' => (string) ($quizArr['text_when_finished'] ?? ''), |
||
| 3290 | 'text_when_finished_failure' => (string) ($quizArr['text_when_finished_failure'] ?? ''), |
||
| 3291 | 'display_category_name' => (int) ($quizArr['display_category_name'] ?? 0), |
||
| 3292 | 'save_correct_answers' => (int) ($quizArr['save_correct_answers'] ?? 0), |
||
| 3293 | 'propagate_neg' => (int) ($quizArr['propagate_neg'] ?? 0), |
||
| 3294 | 'hide_question_title' => (bool) ($quizArr['hide_question_title'] ?? false), |
||
| 3295 | 'hide_question_number' => (int) ($quizArr['hide_question_number'] ?? 0), |
||
| 3296 | 'question_selection_type'=> (int) ($quizArr['question_selection_type'] ?? 0), |
||
| 3297 | 'access_condition' => (string) ($quizArr['access_condition'] ?? ''), |
||
| 3298 | 'pass_percentage' => $quizArr['pass_percentage'] ?? null, |
||
| 3299 | 'start_time' => (string) ($quizArr['start_time'] ?? ''), |
||
| 3300 | 'end_time' => (string) ($quizArr['end_time'] ?? ''), |
||
| 3301 | // We will remap IDs to locals below: |
||
| 3302 | 'question_ids' => [], |
||
| 3303 | 'question_orders' => [], |
||
| 3304 | ]; |
||
| 3305 | |||
| 3306 | // Fill question_ids and orders using the local id map |
||
| 3307 | $localIds = []; |
||
| 3308 | $localOrder = []; |
||
| 3309 | |||
| 3310 | // If we received aligned srcIds/srcOrder, keep that order; otherwise, use question '_order' |
||
| 3311 | if (!empty($srcIds) && !empty($srcOrder) && \count($srcIds) === \count($srcOrder)) { |
||
| 3312 | foreach ($srcIds as $i => $srcQid) { |
||
| 3313 | if (isset($srcToLocalQ[$srcQid])) { |
||
| 3314 | $localIds[] = $srcToLocalQ[$srcQid]; |
||
| 3315 | $localOrder[] = (int) $srcOrder[$i]; |
||
| 3316 | } |
||
| 3317 | } |
||
| 3318 | } else { |
||
| 3319 | // Build order from questions array (_order) if present; otherwise keep list order |
||
| 3320 | usort($questions, static fn(array $a, array $b) => |
||
| 3321 | (int)($a['_order'] ?? 0) <=> (int)($b['_order'] ?? 0)); |
||
| 3322 | foreach ($questions as $qArr) { |
||
| 3323 | $srcQid = (int) ($qArr['id'] ?? 0); |
||
| 3324 | if (isset($srcToLocalQ[$srcQid])) { |
||
| 3325 | $localIds[] = $srcToLocalQ[$srcQid]; |
||
| 3326 | $localOrder[] = (int) ($qArr['_order'] ?? 0); |
||
| 3327 | } |
||
| 3328 | } |
||
| 3329 | } |
||
| 3330 | |||
| 3331 | $payload['question_ids'] = $localIds; |
||
| 3332 | $payload['question_orders'] = $localOrder; |
||
| 3333 | |||
| 3334 | // Store quiz in both bags (constant/string and compat) |
||
| 3335 | $resources[$quizKey][$qidLocal] = |
||
| 3336 | $this->mkLegacyItem($quizKey, $qidLocal, $payload, ['question_ids', 'question_orders']); |
||
| 3337 | $resources[$quizCompatKey][$qidLocal] = $resources[$quizKey][$qidLocal]; |
||
| 3338 | |||
| 3339 | // ---- Import questions (with nested answers), mapping to local ids |
||
| 3340 | foreach ($questions as $qArr) { |
||
| 3341 | $srcQid = (int) ($qArr['id'] ?? 0); |
||
| 3342 | $qid = $srcToLocalQ[$srcQid] ?? $this->nextId($resources[$qqKey]); |
||
| 3343 | |||
| 3344 | $qPayload = [ |
||
| 3345 | 'question' => (string) ($qArr['question'] ?? ''), |
||
| 3346 | 'description' => (string) ($qArr['description'] ?? ''), |
||
| 3347 | 'ponderation' => (float) ($qArr['ponderation'] ?? 0), |
||
| 3348 | 'position' => (int) ($qArr['position'] ?? 0), |
||
| 3349 | 'type' => (int) ($qArr['type'] ?? ($qArr['quiz_type'] ?? 0)), |
||
| 3350 | 'quiz_type' => (int) ($qArr['quiz_type'] ?? ($qArr['type'] ?? 0)), |
||
| 3351 | 'picture' => (string) ($qArr['picture'] ?? ''), |
||
| 3352 | 'level' => (int) ($qArr['level'] ?? 0), |
||
| 3353 | 'extra' => (string) ($qArr['extra'] ?? ''), |
||
| 3354 | 'feedback' => (string) ($qArr['feedback'] ?? ''), |
||
| 3355 | 'question_code' => (string) ($qArr['question_code'] ?? ''), |
||
| 3356 | 'mandatory' => (int) ($qArr['mandatory'] ?? 0), |
||
| 3357 | 'duration' => $qArr['duration'] ?? null, |
||
| 3358 | 'parent_media_id' => $qArr['parent_media_id'] ?? null, |
||
| 3359 | 'answers' => [], |
||
| 3360 | ]; |
||
| 3361 | |||
| 3362 | // Answers: prefer nested in questions.json; fallback to answers.json (flat) |
||
| 3363 | $ansList = []; |
||
| 3364 | if (isset($qArr['answers']) && \is_array($qArr['answers'])) { |
||
| 3365 | $ansList = $qArr['answers']; |
||
| 3366 | } else { |
||
| 3367 | // Try to reconstruct from flat answers.json |
||
| 3368 | $ansFlat = $this->readJsonFile($answersFile); |
||
| 3369 | foreach ((array) ($ansFlat['answers'] ?? []) as $row) { |
||
| 3370 | if ((int) ($row['question_id'] ?? -1) === $srcQid && isset($row['data'])) { |
||
| 3371 | $ansList[] = $row['data']; |
||
| 3372 | } |
||
| 3373 | } |
||
| 3374 | } |
||
| 3375 | |||
| 3376 | $pos = 1; |
||
| 3377 | foreach ($ansList as $a) { |
||
| 3378 | $qPayload['answers'][] = [ |
||
| 3379 | 'id' => (int) ($a['id'] ?? $this->nextId($qPayload['answers'])), |
||
| 3380 | 'answer' => (string) ($a['answer'] ?? ''), |
||
| 3381 | 'comment' => (string) ($a['comment'] ?? ''), |
||
| 3382 | 'ponderation' => (float) ($a['ponderation'] ?? 0), |
||
| 3383 | 'position' => (int) ($a['position'] ?? $pos), |
||
| 3384 | 'hotspot_coordinates' => $a['hotspot_coordinates'] ?? null, |
||
| 3385 | 'hotspot_type' => $a['hotspot_type'] ?? null, |
||
| 3386 | 'correct' => $a['correct'] ?? null, |
||
| 3387 | ]; |
||
| 3388 | $pos++; |
||
| 3389 | } |
||
| 3390 | |||
| 3391 | // Optional: MATF options (as in builder) |
||
| 3392 | if (isset($qArr['question_options']) && \is_array($qArr['question_options'])) { |
||
| 3393 | $qPayload['question_options'] = array_map(static fn ($o) => [ |
||
| 3394 | 'id' => (int) ($o['id'] ?? 0), |
||
| 3395 | 'name' => (string) ($o['name'] ?? ''), |
||
| 3396 | 'position' => (int) ($o['position'] ?? 0), |
||
| 3397 | ], $qArr['question_options']); |
||
| 3398 | } |
||
| 3399 | |||
| 3400 | $resources[$qqKey][$qid] = |
||
| 3401 | $this->mkLegacyItem($qqKey, $qid, $qPayload, ['answers', 'question_options']); |
||
| 3402 | $resources[$qqCompatKey][$qid] = $resources[$qqKey][$qid]; |
||
| 3403 | } |
||
| 3404 | |||
| 3405 | $imported++; |
||
| 3406 | } |
||
| 3407 | |||
| 3408 | closedir($dh); |
||
| 3409 | |||
| 3410 | if ($this->debug) { |
||
| 3411 | @error_log("MOODLE_IMPORT: Quizzes from meta imported={$imported}"); |
||
| 3412 | } |
||
| 3413 | return $imported > 0; |
||
| 3414 | } |
||
| 3415 | |||
| 3416 | /** Find an existing quiz by title (case-insensitive) in any of the provided bags. */ |
||
| 3417 | private function findExistingQuizIdByTitle(array $resources, string $title, array $bags): ?int |
||
| 3418 | { |
||
| 3419 | $needle = mb_strtolower(trim($title)); |
||
| 3420 | foreach ($bags as $bag) { |
||
| 3421 | foreach ((array) ($resources[$bag] ?? []) as $id => $q) { |
||
| 3422 | $arr = \is_object($q) ? get_object_vars($q) : (array) $q; |
||
| 3423 | $t = (string) ($arr['title'] ?? $arr['name'] ?? ''); |
||
| 3424 | if ($needle !== '' && mb_strtolower($t) === $needle) { |
||
| 3425 | return (int) $id; |
||
| 3426 | } |
||
| 3427 | } |
||
| 3428 | } |
||
| 3429 | return null; |
||
| 3430 | } |
||
| 3431 | |||
| 3432 | /** Quick probe: do we have at least one chamilo/quiz/quiz_quiz.json + questions.json ? */ |
||
| 3433 | private function hasQuizMeta(string $workDir): bool |
||
| 3434 | { |
||
| 3435 | $base = rtrim($workDir, '/').'/chamilo/quiz'; |
||
| 3436 | if (!is_dir($base)) return false; |
||
| 3437 | if (!$dh = @opendir($base)) return false; |
||
| 3438 | while (false !== ($e = readdir($dh))) { |
||
| 3439 | if ($e === '.' || $e === '..') continue; |
||
| 3440 | $dir = $base.'/'.$e; |
||
| 3441 | if (is_dir($dir) && str_starts_with($e, 'quiz_') |
||
| 3442 | && is_file($dir.'/quiz.json') && is_file($dir.'/questions.json')) { |
||
| 3443 | closedir($dh); |
||
| 3444 | return true; |
||
| 3445 | } |
||
| 3446 | } |
||
| 3447 | closedir($dh); |
||
| 3448 | return false; |
||
| 3449 | } |
||
| 3450 | |||
| 3451 | /** Quick probe: typical LearnpathMetaExport artifacts */ |
||
| 3452 | private function hasLearnpathMeta(string $workDir): bool |
||
| 3453 | { |
||
| 3454 | $base = rtrim($workDir, '/').'/chamilo/learnpath'; |
||
| 3455 | return is_dir($base) && (is_file($base.'/index.json') || is_file($base.'/categories.json')); |
||
| 3456 | } |
||
| 3457 | |||
| 3458 | /** Cheap reader: obtain <quiz><name> from module xml without building resources. */ |
||
| 3459 | private function peekQuizTitle(string $moduleXml): ?string |
||
| 3460 | { |
||
| 3461 | try { |
||
| 3462 | $doc = $this->loadXml($moduleXml); |
||
| 3463 | $xp = new DOMXPath($doc); |
||
| 3464 | $name = $xp->query('//quiz/name')->item(0)?->nodeValue ?? null; |
||
| 3465 | return $name ? (string) $name : null; |
||
| 3466 | } catch (\Throwable) { |
||
| 3467 | return null; |
||
| 3468 | } |
||
| 3469 | } |
||
| 3470 | |||
| 3471 | /** |
||
| 3472 | * For LP fallback items with missing 'ref', try to resolve by title against resources bags. |
||
| 3473 | * Matching is case-insensitive and checks both 'title' and 'name' fields in resources. |
||
| 3474 | */ |
||
| 3475 | private function backfillLpRefsFromResources(array &$lpMap, array $resources, array $bags): void |
||
| 3476 | { |
||
| 3477 | // Build lookup: item_type => [lower(title) => id] |
||
| 3478 | $lookups = []; |
||
| 3479 | |||
| 3480 | foreach ($bags as $bag) { |
||
| 3481 | foreach ((array) ($resources[$bag] ?? []) as $id => $wrap) { |
||
| 3482 | $obj = \is_object($wrap) ? $wrap : (object) $wrap; |
||
| 3483 | // candidate fields |
||
| 3484 | $title = ''; |
||
| 3485 | if (isset($obj->title) && \is_string($obj->title)) { |
||
| 3486 | $title = $obj->title; |
||
| 3487 | } elseif (isset($obj->name) && \is_string($obj->name)) { |
||
| 3488 | $title = $obj->name; |
||
| 3489 | } elseif (isset($obj->obj) && \is_object($obj->obj)) { |
||
| 3490 | $title = (string) ($obj->obj->title ?? $obj->obj->name ?? ''); |
||
| 3491 | } |
||
| 3492 | if ($title === '') continue; |
||
| 3493 | |||
| 3494 | $key = mb_strtolower($title); |
||
| 3495 | $typeKey = $this->normalizeItemTypeKey($bag); // e.g. 'quiz' for ['quiz','quizzes'] |
||
| 3496 | if (!isset($lookups[$typeKey])) $lookups[$typeKey] = []; |
||
| 3497 | $lookups[$typeKey][$key] = (int) $id; |
||
| 3498 | } |
||
| 3499 | } |
||
| 3500 | |||
| 3501 | // Walk all LP items and fill 'ref' when empty |
||
| 3502 | foreach ($lpMap as &$lp) { |
||
| 3503 | foreach ($lp['items'] as &$it) { |
||
| 3504 | if (!empty($it['ref'])) continue; |
||
| 3505 | $type = $this->normalizeItemTypeKey((string) ($it['item_type'] ?? '')); |
||
| 3506 | $t = mb_strtolower((string) ($it['title'] ?? '')); |
||
| 3507 | if ($t !== '' && isset($lookups[$type][$t])) { |
||
| 3508 | $it['ref'] = $lookups[$type][$t]; |
||
| 3509 | } |
||
| 3510 | } |
||
| 3511 | unset($it); |
||
| 3512 | } |
||
| 3513 | unset($lp); |
||
| 3514 | } |
||
| 3515 | |||
| 3516 | /** Normalize various bag/item_type labels into a stable key used in lookup. */ |
||
| 3517 | private function normalizeItemTypeKey(string $s): string |
||
| 3518 | { |
||
| 3519 | $s = strtolower($s); |
||
| 3520 | return match ($s) { |
||
| 3521 | 'quizzes', 'quiz', \defined('RESOURCE_QUIZ') ? strtolower((string) RESOURCE_QUIZ) : 'quiz' => 'quiz', |
||
| 3522 | 'document', 'documents' => 'document', |
||
| 3523 | 'forum', 'forums' => 'forum', |
||
| 3524 | 'link', 'links' => 'link', |
||
| 3525 | 'scorm', 'scorm_documents' => 'scorm', |
||
| 3526 | 'coursedescription' => 'course_description', |
||
| 3527 | 'course_descriptions' => 'course_description', |
||
| 3528 | default => $s, |
||
| 3529 | }; |
||
| 3530 | } |
||
| 3531 | |||
| 3532 | /** Merge bag aliases into canonical keys to avoid duplicate groups. */ |
||
| 3533 | private function canonicalizeResourceBags(array $res): array |
||
| 3598 | } |
||
| 3599 | |||
| 3600 | /** |
||
| 3601 | * Import Thematic sidecars written by ThematicMetaExport. |
||
| 3602 | * Authoritative: if present, replaces any pre-filled bag. |
||
| 3603 | * |
||
| 3604 | * @return bool true if at least one thematic was imported |
||
| 3605 | */ |
||
| 3606 | private function tryImportThematicMeta(string $workDir, array &$resources): bool |
||
| 3607 | { |
||
| 3608 | $THEM_KEY = \defined('RESOURCE_THEMATIC') ? RESOURCE_THEMATIC : 'thematic'; |
||
| 3609 | |||
| 3610 | $base = rtrim($workDir, '/').'/chamilo/thematic'; |
||
| 3611 | if (!is_dir($base)) { |
||
| 3612 | return false; |
||
| 3613 | } |
||
| 3614 | |||
| 3615 | // 1) Discover files (prefer manifest, fallback to glob) |
||
| 3616 | $files = []; |
||
| 3617 | $manifest = @json_decode((string)@file_get_contents(rtrim($workDir, '/').'/chamilo/manifest.json'), true); |
||
| 3618 | if (\is_array($manifest['items'] ?? null)) { |
||
| 3619 | foreach ($manifest['items'] as $it) { |
||
| 3620 | if (($it['kind'] ?? '') === 'thematic' && !empty($it['path'])) { |
||
| 3621 | $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/'); |
||
| 3622 | if (is_file($path)) { |
||
| 3623 | $files[] = $path; |
||
| 3624 | } |
||
| 3625 | } |
||
| 3626 | } |
||
| 3627 | } |
||
| 3628 | if (empty($files)) { |
||
| 3629 | foreach ((array)@glob($base.'/thematic_*.json') as $f) { |
||
| 3630 | if (is_file($f)) { |
||
| 3631 | $files[] = $f; |
||
| 3632 | } |
||
| 3633 | } |
||
| 3634 | } |
||
| 3635 | if (empty($files)) { |
||
| 3636 | return false; |
||
| 3637 | } |
||
| 3638 | |||
| 3639 | // Authoritative: reset bag to avoid duplicates |
||
| 3640 | $resources[$THEM_KEY] = []; |
||
| 3641 | |||
| 3642 | $imported = 0; |
||
| 3643 | foreach ($files as $f) { |
||
| 3644 | $payload = @json_decode((string)@file_get_contents($f), true); |
||
| 3645 | if (!\is_array($payload)) { |
||
| 3646 | continue; |
||
| 3647 | } |
||
| 3648 | |||
| 3649 | // Exporter shape: { "type":"thematic", ..., "title","content","active","advances":[...], "plans":[...] } |
||
| 3650 | $title = (string)($payload['title'] ?? 'Thematic'); |
||
| 3651 | $content = (string)($payload['content'] ?? ''); |
||
| 3652 | $active = (int) ($payload['active'] ?? 1); |
||
| 3653 | |||
| 3654 | // Prefer explicit id inside nested shapes if present |
||
| 3655 | $iid = (int)($payload['id'] ?? 0); |
||
| 3656 | if ($iid <= 0) { |
||
| 3657 | // Derive from filename thematic_{moduleId}.json or moduleid |
||
| 3658 | $iid = (int)($payload['moduleid'] ?? 0); |
||
| 3659 | if ($iid <= 0 && preg_match('/thematic_(\d+)\.json$/', (string)$f, $m)) { |
||
| 3660 | $iid = (int)$m[1]; |
||
| 3661 | } |
||
| 3662 | if ($iid <= 0) { |
||
| 3663 | $iid = $this->nextId($resources[$THEM_KEY] ?? []); |
||
| 3664 | } |
||
| 3665 | } |
||
| 3666 | |||
| 3667 | // Normalize lists |
||
| 3668 | $advances = []; |
||
| 3669 | foreach ((array)($payload['advances'] ?? []) as $a) { |
||
| 3670 | $a = (array)$a; |
||
| 3671 | $advances[] = [ |
||
| 3672 | 'id' => (int) ($a['id'] ?? ($a['iid'] ?? 0)), |
||
| 3673 | 'thematic_id' => (int) ($a['thematic_id'] ?? $iid), |
||
| 3674 | 'content' => (string)($a['content'] ?? ''), |
||
| 3675 | 'start_date' => (string)($a['start_date'] ?? ''), |
||
| 3676 | 'duration' => (int) ($a['duration'] ?? 0), |
||
| 3677 | 'done_advance' => (bool) ($a['done_advance'] ?? false), |
||
| 3678 | 'attendance_id' => (int) ($a['attendance_id'] ?? 0), |
||
| 3679 | 'room_id' => (int) ($a['room_id'] ?? 0), |
||
| 3680 | ]; |
||
| 3681 | } |
||
| 3682 | |||
| 3683 | $plans = []; |
||
| 3684 | foreach ((array)($payload['plans'] ?? []) as $p) { |
||
| 3685 | $p = (array)$p; |
||
| 3686 | $plans[] = [ |
||
| 3687 | 'id' => (int) ($p['id'] ?? ($p['iid'] ?? 0)), |
||
| 3688 | 'thematic_id' => (int) ($p['thematic_id'] ?? $iid), |
||
| 3689 | 'title' => (string)($p['title'] ?? ''), |
||
| 3690 | 'description' => (string)($p['description'] ?? ''), |
||
| 3691 | 'description_type' => (int) ($p['description_type'] ?? 0), |
||
| 3692 | ]; |
||
| 3693 | } |
||
| 3694 | |||
| 3695 | // mkLegacyItem wrapper with explicit list fields |
||
| 3696 | $item = $this->mkLegacyItem($THEM_KEY, $iid, [ |
||
| 3697 | 'id' => $iid, |
||
| 3698 | 'title' => $title, |
||
| 3699 | 'content' => $content, |
||
| 3700 | 'active' => $active, |
||
| 3701 | ], ['thematic_advance_list','thematic_plan_list']); |
||
| 3702 | |||
| 3703 | // Attach lists on the wrapper (builder-friendly) |
||
| 3704 | $item->thematic_advance_list = $advances; |
||
| 3705 | $item->thematic_plan_list = $plans; |
||
| 3706 | |||
| 3707 | $resources[$THEM_KEY][$iid] = $item; |
||
| 3708 | $imported++; |
||
| 3709 | } |
||
| 3710 | |||
| 3711 | if ($this->debug) { |
||
| 3712 | @error_log('MOODLE_IMPORT: Thematic meta imported='.$imported); |
||
| 3713 | } |
||
| 3714 | |||
| 3715 | return $imported > 0; |
||
| 3716 | } |
||
| 3717 | |||
| 3718 | /** |
||
| 3719 | * Import Attendance sidecars written by AttendanceMetaExport. |
||
| 3720 | * Authoritative: if present, replaces any pre-filled bag to avoid duplicates. |
||
| 3721 | * |
||
| 3722 | * @return bool true if at least one attendance was imported |
||
| 3723 | */ |
||
| 3724 | private function tryImportAttendanceMeta(string $workDir, array &$resources): bool |
||
| 3725 | { |
||
| 3726 | $ATT_KEY = \defined('RESOURCE_ATTENDANCE') ? RESOURCE_ATTENDANCE : 'attendance'; |
||
| 3727 | $base = rtrim($workDir, '/').'/chamilo/attendance'; |
||
| 3728 | if (!is_dir($base)) { |
||
| 3729 | return false; |
||
| 3730 | } |
||
| 3731 | |||
| 3732 | // 1) Discover files via manifest (preferred), fallback to glob |
||
| 3733 | $files = []; |
||
| 3734 | $manifestFile = rtrim($workDir, '/').'/chamilo/manifest.json'; |
||
| 3735 | $manifest = @json_decode((string)@file_get_contents($manifestFile), true); |
||
| 3736 | if (\is_array($manifest['items'] ?? null)) { |
||
| 3737 | foreach ($manifest['items'] as $it) { |
||
| 3738 | if (($it['kind'] ?? '') === 'attendance' && !empty($it['path'])) { |
||
| 3739 | $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/'); |
||
| 3740 | if (is_file($path)) { |
||
| 3741 | $files[] = $path; |
||
| 3742 | } |
||
| 3743 | } |
||
| 3744 | } |
||
| 3745 | } |
||
| 3746 | if (empty($files)) { |
||
| 3747 | foreach ((array)@glob($base.'/attendance_*.json') as $f) { |
||
| 3748 | if (is_file($f)) { |
||
| 3749 | $files[] = $f; |
||
| 3750 | } |
||
| 3751 | } |
||
| 3752 | } |
||
| 3753 | if (empty($files)) { |
||
| 3754 | return false; |
||
| 3755 | } |
||
| 3756 | |||
| 3757 | // Authoritative: clear bag to avoid duplicates |
||
| 3758 | $resources[$ATT_KEY] = []; |
||
| 3759 | |||
| 3760 | $imported = 0; |
||
| 3761 | foreach ($files as $f) { |
||
| 3762 | $payload = @json_decode((string)@file_get_contents($f), true); |
||
| 3763 | if (!\is_array($payload)) { |
||
| 3764 | continue; |
||
| 3765 | } |
||
| 3766 | |||
| 3767 | // ---- Map top-level fields (robust against naming variants) |
||
| 3768 | $iid = (int)($payload['id'] ?? 0); |
||
| 3769 | if ($iid <= 0) { |
||
| 3770 | $iid = (int)($payload['moduleid'] ?? 0); |
||
| 3771 | } |
||
| 3772 | if ($iid <= 0 && preg_match('/attendance_(\d+)\.json$/', (string)$f, $m)) { |
||
| 3773 | $iid = (int)$m[1]; |
||
| 3774 | } |
||
| 3775 | if ($iid <= 0) { |
||
| 3776 | $iid = $this->nextId($resources[$ATT_KEY] ?? []); |
||
| 3777 | } |
||
| 3778 | |||
| 3779 | $title = (string)($payload['title'] ?? $payload['name'] ?? 'Attendance'); |
||
| 3780 | $desc = (string)($payload['description'] ?? $payload['intro'] ?? ''); |
||
| 3781 | $active= (int) ($payload['active'] ?? 1); |
||
| 3782 | $locked= (int) ($payload['locked'] ?? 0); |
||
| 3783 | |||
| 3784 | // Qualify block may be nested or flattened |
||
| 3785 | $qual = \is_array($payload['qualify'] ?? null) ? $payload['qualify'] : []; |
||
| 3786 | $qualTitle = (string)($qual['title'] ?? $payload['attendance_qualify_title'] ?? ''); |
||
| 3787 | $qualMax = (int) ($qual['max'] ?? $payload['attendance_qualify_max'] ?? 0); |
||
| 3788 | $weight = (float) ($qual['weight']?? $payload['attendance_weight'] ?? 0.0); |
||
| 3789 | |||
| 3790 | // ---- Normalize calendars |
||
| 3791 | $calIn = (array)($payload['calendars'] ?? []); |
||
| 3792 | $cals = []; |
||
| 3793 | foreach ($calIn as $c) { |
||
| 3794 | if (!\is_array($c) && !\is_object($c)) { |
||
| 3795 | continue; |
||
| 3796 | } |
||
| 3797 | $c = (array)$c; |
||
| 3798 | $cid = (int)($c['id'] ?? $c['iid'] ?? 0); |
||
| 3799 | $aid = (int)($c['attendance_id'] ?? $iid); |
||
| 3800 | $dt = (string)($c['date_time'] ?? $c['datetime'] ?? ''); |
||
| 3801 | $done = (bool) ($c['done_attendance'] ?? false); |
||
| 3802 | $block = (bool) ($c['blocked'] ?? false); |
||
| 3803 | $dur = $c['duration'] ?? null; |
||
| 3804 | $dur = (null !== $dur) ? (int)$dur : null; |
||
| 3805 | |||
| 3806 | $cals[] = [ |
||
| 3807 | 'id' => $cid > 0 ? $cid : $this->nextId($cals), |
||
| 3808 | 'attendance_id' => $aid, |
||
| 3809 | 'date_time' => $dt, |
||
| 3810 | 'done_attendance' => $done, |
||
| 3811 | 'blocked' => $block, |
||
| 3812 | 'duration' => $dur, |
||
| 3813 | ]; |
||
| 3814 | } |
||
| 3815 | |||
| 3816 | // ---- Wrap as legacy item compatible with builder |
||
| 3817 | $item = $this->mkLegacyItem( |
||
| 3818 | $ATT_KEY, |
||
| 3819 | $iid, |
||
| 3820 | [ |
||
| 3821 | 'id' => $iid, |
||
| 3822 | 'title' => $title, |
||
| 3823 | 'name' => $title, // keep alias |
||
| 3824 | 'description' => $desc, |
||
| 3825 | 'active' => $active, |
||
| 3826 | 'attendance_qualify_title' => $qualTitle, |
||
| 3827 | 'attendance_qualify_max' => $qualMax, |
||
| 3828 | 'attendance_weight' => $weight, |
||
| 3829 | 'locked' => $locked, |
||
| 3830 | ], |
||
| 3831 | ['attendance_calendar'] // list fields that will be appended below |
||
| 3832 | ); |
||
| 3833 | |||
| 3834 | // Attach calendars collection on the wrapper |
||
| 3835 | $item->attendance_calendar = $cals; |
||
| 3836 | |||
| 3837 | $resources[$ATT_KEY][$iid] = $item; |
||
| 3838 | $imported++; |
||
| 3839 | } |
||
| 3840 | |||
| 3841 | if ($this->debug) { |
||
| 3842 | @error_log('MOODLE_IMPORT: Attendance meta imported='.$imported); |
||
| 3843 | } |
||
| 3844 | |||
| 3845 | return $imported > 0; |
||
| 3846 | } |
||
| 3847 | |||
| 3848 | /** |
||
| 3849 | * Import Gradebook sidecars written by GradebookMetaExport. |
||
| 3850 | * Authoritative: if present, replaces any pre-filled bag to avoid duplicates. |
||
| 3851 | * |
||
| 3852 | * @return bool true if at least one gradebook was imported |
||
| 3853 | */ |
||
| 3854 | private function tryImportGradebookMeta(string $workDir, array &$resources): bool |
||
| 3929 | } |
||
| 3930 | |||
| 3931 | /** |
||
| 3932 | * Read activities/wiki_{moduleId}/wiki.xml and return: |
||
| 3933 | * - meta: module-level info (name, moduleid, sectionid) |
||
| 3934 | * - pages: array of pages with id,title,content,contentformat,version,userid,timecreated,timemodified |
||
| 3935 | */ |
||
| 3936 | private function readWikiModuleFull(string $xmlPath): array |
||
| 3937 | { |
||
| 3938 | $doc = $this->loadXml($xmlPath); |
||
| 3939 | $xp = new DOMXPath($doc); |
||
| 3940 | |||
| 3941 | // Module meta |
||
| 3942 | $activity = $xp->query('/activity')->item(0); |
||
| 3943 | $moduleId = (int) ($activity?->getAttribute('moduleid') ?? 0); |
||
| 3944 | |||
| 3945 | $nameNode = $xp->query('//wiki/name')->item(0); |
||
| 3946 | $name = (string) ($nameNode?->nodeValue ?? 'Wiki'); |
||
| 3947 | |||
| 3948 | // Some exports put sectionid on <activity>; default 0 |
||
| 3949 | $sectionId = (int) ($xp->query('/activity')->item(0)?->getAttribute('contextid') ?? 0); |
||
| 3950 | |||
| 3951 | $pages = []; |
||
| 3952 | foreach ($xp->query('//wiki/subwikis/subwiki/pages/page') as $node) { |
||
| 3953 | /** @var DOMElement $node */ |
||
| 3954 | $pid = (int) ($node->getAttribute('id') ?: 0); |
||
| 3955 | $title = (string) ($node->getElementsByTagName('title')->item(0)?->nodeValue ?? ('Wiki page '.$pid)); |
||
| 3956 | $uid = (int) ($node->getElementsByTagName('userid')->item(0)?->nodeValue ?? 0); |
||
| 3957 | |||
| 3958 | $timeCreated = (int) ($node->getElementsByTagName('timecreated')->item(0)?->nodeValue ?? time()); |
||
| 3959 | $timeModified = (int) ($node->getElementsByTagName('timemodified')->item(0)?->nodeValue ?? $timeCreated); |
||
| 3960 | |||
| 3961 | // Prefer cachedcontent; fallback to the last <versions>/<version>/content |
||
| 3962 | $cached = $node->getElementsByTagName('cachedcontent')->item(0)?->nodeValue ?? ''; |
||
| 3963 | $content = (string) $cached; |
||
| 3964 | $version = 1; |
||
| 3965 | |||
| 3966 | $versionsEl = $node->getElementsByTagName('versions')->item(0); |
||
| 3967 | if ($versionsEl instanceof DOMElement) { |
||
| 3968 | $versNodes = $versionsEl->getElementsByTagName('version'); |
||
| 3969 | if ($versNodes->length > 0) { |
||
| 3970 | $last = $versNodes->item($versNodes->length - 1); |
||
| 3971 | $vHtml = $last?->getElementsByTagName('content')->item(0)?->nodeValue ?? ''; |
||
| 3972 | $vNum = (int) ($last?->getElementsByTagName('version')->item(0)?->nodeValue ?? 1); |
||
| 3973 | if (trim((string)$vHtml) !== '') { |
||
| 3974 | $content = (string) $vHtml; |
||
| 3975 | } |
||
| 3976 | if ($vNum > 0) { |
||
| 3977 | $version = $vNum; |
||
| 3978 | } |
||
| 3979 | } |
||
| 3980 | } |
||
| 3981 | |||
| 3982 | $pages[] = [ |
||
| 3983 | 'id' => $pid, |
||
| 3984 | 'title' => $title, |
||
| 3985 | 'content' => $content, |
||
| 3986 | 'contentformat' => 'html', |
||
| 3987 | 'version' => $version, |
||
| 3988 | 'timecreated' => $timeCreated, |
||
| 3989 | 'timemodified' => $timeModified, |
||
| 3990 | 'userid' => $uid, |
||
| 3991 | 'reflink' => $this->slugify($title), |
||
| 3992 | ]; |
||
| 3993 | } |
||
| 3994 | |||
| 3995 | // Stable order |
||
| 3996 | usort($pages, fn(array $a, array $b) => $a['id'] <=> $b['id']); |
||
| 3997 | |||
| 3998 | return [ |
||
| 3999 | [ |
||
| 4000 | 'moduleid' => $moduleId, |
||
| 4001 | 'sectionid' => $sectionId, |
||
| 4002 | 'name' => $name, |
||
| 4003 | ], |
||
| 4004 | $pages, |
||
| 4005 | ]; |
||
| 4006 | } |
||
| 4007 | |||
| 4008 | private function rewritePluginfileBasic(string $html, string $context): string |
||
| 4009 | { |
||
| 4010 | if ($html === '' || !str_contains($html, '@@PLUGINFILE@@')) { |
||
| 4011 | return $html; |
||
| 4012 | } |
||
| 4013 | |||
| 4014 | // src/href/poster/data |
||
| 4015 | $html = (string)preg_replace( |
||
| 4016 | '~\b(src|href|poster|data)\s*=\s*([\'"])@@PLUGINFILE@@/([^\'"]+)\2~i', |
||
| 4017 | '$1=$2/document/moodle_pages/$3$2', |
||
| 4018 | $html |
||
| 4019 | ); |
||
| 4020 | |||
| 4021 | // url(...) in inline styles |
||
| 4022 | $html = (string)preg_replace( |
||
| 4023 | '~url\((["\']?)@@PLUGINFILE@@/([^)\'"]+)\1\)~i', |
||
| 4024 | 'url($1/document/moodle_pages/$2$1)', |
||
| 4025 | $html |
||
| 4026 | ); |
||
| 4027 | |||
| 4028 | return $html; |
||
| 4029 | } |
||
| 4030 | |||
| 4031 | /** Check if chamilo manifest has any 'announcement' kind to avoid duplicates */ |
||
| 4032 | private function hasChamiloAnnouncementMeta(string $exportRoot): bool |
||
| 4045 | } |
||
| 4046 | |||
| 4047 | /** Read minimal forum header (type, name) */ |
||
| 4048 | private function readForumHeader(string $moduleXml): array |
||
| 4049 | { |
||
| 4050 | $doc = $this->loadXml($moduleXml); |
||
| 4051 | $xp = new \DOMXPath($doc); |
||
| 4052 | $type = (string) ($xp->query('//forum/type')->item(0)?->nodeValue ?? ''); |
||
| 4053 | $name = (string) ($xp->query('//forum/name')->item(0)?->nodeValue ?? ''); |
||
| 4054 | return ['type' => $type, 'name' => $name]; |
||
| 4055 | } |
||
| 4056 | |||
| 4057 | /** |
||
| 4058 | * Parse forum.xml (news) → array of announcements: |
||
| 4059 | * [ |
||
| 4060 | * [ |
||
| 4061 | * 'title' => string, |
||
| 4062 | * 'html' => string, |
||
| 4063 | * 'date' => 'Y-m-d H:i:s', |
||
| 4064 | * 'attachments' => [ {path, filename, size, comment, asset_relpath}... ], |
||
| 4065 | * 'first_path' => string, |
||
| 4066 | * 'first_name' => string, |
||
| 4067 | * 'first_size' => int, |
||
| 4068 | * ], ... |
||
| 4069 | * ] |
||
| 4070 | */ |
||
| 4071 | private function readAnnouncementsFromForum(string $moduleXml, string $exportRoot): array |
||
| 4072 | { |
||
| 4073 | $doc = $this->loadXml($moduleXml); |
||
| 4074 | $xp = new \DOMXPath($doc); |
||
| 4075 | |||
| 4076 | $anns = []; |
||
| 4077 | // One discussion = one announcement; firstpost = main message |
||
| 4078 | foreach ($xp->query('//forum/discussions/discussion') as $d) { |
||
| 4079 | /** @var \DOMElement $d */ |
||
| 4080 | $title = (string) ($d->getElementsByTagName('name')->item(0)?->nodeValue ?? 'Announcement'); |
||
| 4081 | $firstPostId = (int) ($d->getElementsByTagName('firstpost')->item(0)?->nodeValue ?? 0); |
||
| 4082 | $created = (int) ($d->getElementsByTagName('timemodified')->item(0)?->nodeValue ?? time()); |
||
| 4083 | |||
| 4084 | // find post by id |
||
| 4085 | $postNode = null; |
||
| 4086 | foreach ($d->getElementsByTagName('post') as $p) { |
||
| 4087 | /** @var \DOMElement $p */ |
||
| 4088 | $pid = (int) $p->getAttribute('id'); |
||
| 4089 | if ($pid === $firstPostId || ($firstPostId === 0 && !$postNode)) { |
||
| 4090 | $postNode = $p; |
||
| 4091 | if ($pid === $firstPostId) { break; } |
||
| 4092 | } |
||
| 4093 | } |
||
| 4094 | if (!$postNode) { continue; } |
||
| 4095 | |||
| 4096 | $subject = (string) ($postNode->getElementsByTagName('subject')->item(0)?->nodeValue ?? $title); |
||
| 4097 | $message = (string) ($postNode->getElementsByTagName('message')->item(0)?->nodeValue ?? ''); |
||
| 4098 | $createdPost = (int) ($postNode->getElementsByTagName('created')->item(0)?->nodeValue ?? $created); |
||
| 4099 | |||
| 4100 | // Normalize HTML and rewrite @@PLUGINFILE@@ |
||
| 4101 | $html = $this->rewritePluginfileForAnnouncements($message, $exportRoot, (int)$postNode->getAttribute('id')); |
||
| 4102 | |||
| 4103 | // Attachments from files.xml (component=mod_forum, filearea=post, itemid=postId) |
||
| 4104 | $postId = (int) $postNode->getAttribute('id'); |
||
| 4105 | $attachments = $this->extractForumPostAttachments($exportRoot, $postId); |
||
| 4106 | |||
| 4107 | // First attachment info (builder-style) |
||
| 4108 | $first = $attachments[0] ?? null; |
||
| 4109 | $anns[] = [ |
||
| 4110 | 'title' => $subject !== '' ? $subject : $title, |
||
| 4111 | 'html' => $html, |
||
| 4112 | 'date' => date('Y-m-d H:i:s', $createdPost ?: $created), |
||
| 4113 | 'attachments' => $attachments, |
||
| 4114 | 'first_path' => (string) ($first['path'] ?? ''), |
||
| 4115 | 'first_name' => (string) ($first['filename'] ?? ''), |
||
| 4116 | 'first_size' => (int) ($first['size'] ?? 0), |
||
| 4117 | ]; |
||
| 4118 | } |
||
| 4119 | |||
| 4120 | return $anns; |
||
| 4121 | } |
||
| 4122 | |||
| 4123 | /** |
||
| 4124 | * Rewrite @@PLUGINFILE@@ URLs in forum messages to point to /document/announcements/{postId}/<file>. |
||
| 4125 | * The physical copy is handled by extractForumPostAttachments(). |
||
| 4126 | */ |
||
| 4127 | private function rewritePluginfileForAnnouncements(string $html, string $exportRoot, int $postId): string |
||
| 4128 | { |
||
| 4129 | if ($html === '' || !str_contains($html, '@@PLUGINFILE@@')) { return $html; } |
||
| 4130 | |||
| 4131 | $targetBase = '/document/announcements/'.$postId.'/'; |
||
| 4132 | |||
| 4133 | // src/href/poster/data |
||
| 4134 | $html = (string)preg_replace( |
||
| 4135 | '~\b(src|href|poster|data)\s*=\s*([\'"])@@PLUGINFILE@@/([^\'"]+)\2~i', |
||
| 4136 | '$1=$2'.$targetBase.'$3$2', |
||
| 4137 | $html |
||
| 4138 | ); |
||
| 4139 | |||
| 4140 | // url(...) in inline styles |
||
| 4141 | $html = (string)preg_replace( |
||
| 4142 | '~url\((["\']?)@@PLUGINFILE@@/([^)\'"]+)\1\)~i', |
||
| 4143 | 'url($1'.$targetBase.'$2$1)', |
||
| 4144 | $html |
||
| 4145 | ); |
||
| 4146 | |||
| 4147 | return $html; |
||
| 4148 | } |
||
| 4149 | |||
| 4150 | /** |
||
| 4151 | * Copy attachments for a forum post from files.xml store to /document/announcements/{postId}/ |
||
| 4152 | * and return normalized descriptors for the announcement payload. |
||
| 4153 | */ |
||
| 4154 | private function extractForumPostAttachments(string $exportRoot, int $postId): array |
||
| 4155 | { |
||
| 4156 | $fx = rtrim($exportRoot, '/').'/files.xml'; |
||
| 4157 | if (!is_file($fx)) { return []; } |
||
| 4158 | |||
| 4159 | $doc = $this->loadXml($fx); |
||
| 4160 | $xp = new \DOMXPath($doc); |
||
| 4161 | |||
| 4162 | // files/file with these conditions |
||
| 4163 | $q = sprintf("//files/file[component='mod_forum' and filearea='post' and itemid='%d']", $postId); |
||
| 4164 | $list = $xp->query($q); |
||
| 4165 | |||
| 4166 | if ($list->length === 0) { return []; } |
||
| 4167 | |||
| 4168 | $destBase = rtrim($exportRoot, '/').'/document/announcements/'.$postId; |
||
| 4169 | $this->ensureDir($destBase); |
||
| 4170 | |||
| 4171 | $out = []; |
||
| 4172 | foreach ($list as $f) { |
||
| 4173 | /** @var \DOMElement $f */ |
||
| 4174 | $filename = (string) ($f->getElementsByTagName('filename')->item(0)?->nodeValue ?? ''); |
||
| 4175 | if ($filename === '' || $filename === '.') { continue; } // skip directories |
||
| 4176 | |||
| 4177 | $contenthash = (string) ($f->getElementsByTagName('contenthash')->item(0)?->nodeValue ?? ''); |
||
| 4178 | $filesize = (int) ($f->getElementsByTagName('filesize')->item(0)?->nodeValue ?? 0); |
||
| 4179 | |||
| 4180 | // Moodle file path inside backup: files/aa/bb/<contenthash> |
||
| 4181 | $src = rtrim($exportRoot, '/').'/files/'.substr($contenthash, 0, 2).'/'.substr($contenthash, 2, 2).'/'.$contenthash; |
||
| 4182 | $dst = $destBase.'/'.$filename; |
||
| 4183 | |||
| 4184 | if (is_file($src)) { |
||
| 4185 | @copy($src, $dst); |
||
| 4186 | } else { |
||
| 4187 | // keep record even if missing; size may still be useful |
||
| 4188 | if ($this->debug) { error_log("MOODLE_IMPORT: forum post attachment missing file=$src"); } |
||
| 4189 | } |
||
| 4190 | |||
| 4191 | $rel = 'document/announcements/'.$postId.'/'.$filename; // relative inside backup root |
||
| 4192 | $out[] = [ |
||
| 4193 | 'path' => $rel, // builder sets 'attachment_path' from first item |
||
| 4194 | 'filename' => $filename, |
||
| 4195 | 'size' => $filesize, |
||
| 4196 | 'comment' => '', |
||
| 4197 | 'asset_relpath' => $rel, // mirrors builder's asset_relpath semantics |
||
| 4198 | ]; |
||
| 4199 | } |
||
| 4200 | |||
| 4201 | return $out; |
||
| 4202 | } |
||
| 4203 | |||
| 4204 | private function isNewsForum(array $forumInfo): bool |
||
| 4205 | { |
||
| 4206 | $type = strtolower((string)($forumInfo['type'] ?? '')); |
||
| 4207 | if ($type === 'news') { |
||
| 4208 | return true; |
||
| 4209 | } |
||
| 4210 | $name = strtolower((string)($forumInfo['name'] ?? '')); |
||
| 4211 | $intro = strtolower((string)($forumInfo['description'] ?? $forumInfo['intro'] ?? '')); |
||
| 4212 | |||
| 4213 | // Common names across locales |
||
| 4214 | $nameHints = ['announcement']; |
||
| 4215 | foreach ($nameHints as $h) { |
||
| 4216 | if ($name !== '' && str_contains($name, $h)) { |
||
| 4217 | return true; |
||
| 4218 | } |
||
| 4219 | } |
||
| 4220 | |||
| 4221 | return false; |
||
| 4222 | } |
||
| 4223 | |||
| 4224 | /** |
||
| 4225 | * Detect the special "course homepage" Page exported as activities/page_0. |
||
| 4226 | * Heuristics: |
||
| 4227 | * - directory ends with 'activities/page_0' |
||
| 4228 | * - or <activity id="0" moduleid="0" modulename="page"> |
||
| 4229 | * - or page name equals 'Introduction' (soft signal) |
||
| 4230 | */ |
||
| 4231 | private function looksLikeCourseHomepage(string $dir, string $moduleXml): bool |
||
| 4232 | { |
||
| 4233 | if (preg_match('~/activities/page_0/?$~', $dir)) { |
||
| 4234 | return true; |
||
| 4235 | } |
||
| 4236 | |||
| 4237 | try { |
||
| 4238 | $doc = $this->loadXml($moduleXml); |
||
| 4239 | $xp = new DOMXPath($doc); |
||
| 4240 | |||
| 4241 | $idAttr = $xp->query('/activity/@id')->item(0)?->nodeValue ?? null; |
||
| 4242 | $moduleIdAttr = $xp->query('/activity/@moduleid')->item(0)?->nodeValue ?? null; |
||
| 4243 | $modNameAttr = $xp->query('/activity/@modulename')->item(0)?->nodeValue ?? null; |
||
| 4244 | $nameNode = $xp->query('//page/name')->item(0)?->nodeValue ?? ''; |
||
| 4245 | |||
| 4246 | $id = is_numeric($idAttr) ? (int) $idAttr : null; |
||
| 4247 | $moduleId = is_numeric($moduleIdAttr) ? (int) $moduleIdAttr : null; |
||
| 4248 | $modName = is_string($modNameAttr) ? strtolower($modNameAttr) : ''; |
||
| 4249 | |||
| 4250 | if ($id === 0 && $moduleId === 0 && $modName === 'page') { |
||
| 4251 | return true; |
||
| 4252 | } |
||
| 4253 | if (trim($nameNode) === 'Introduction') { |
||
| 4254 | // Soft hint: do not exclusively rely on this, but keep as fallback |
||
| 4255 | return true; |
||
| 4256 | } |
||
| 4257 | } catch (\Throwable $e) { |
||
| 4258 | // Be tolerant: if parsing fails, just return false |
||
| 4259 | } |
||
| 4260 | |||
| 4261 | return false; |
||
| 4262 | } |
||
| 4263 | |||
| 4264 | /** |
||
| 4265 | * Read <page><content>...</content></page> as decoded HTML. |
||
| 4266 | */ |
||
| 4267 | private function readPageContent(string $moduleXml): string |
||
| 4279 | } |
||
| 4280 | |||
| 4281 | } |
||
| 4282 |
If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration: