| Total Complexity | 369 |
| Total Lines | 1984 |
| Duplicated Lines | 0 % |
| Changes | 1 | ||
| Bugs | 0 | Features | 0 |
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 | public function __construct( |
||
| 43 | private bool $debug = false |
||
| 44 | ) {} |
||
| 45 | |||
| 46 | /** |
||
| 47 | * Builds a Course ready for CourseRestorer::restore(). |
||
| 48 | */ |
||
| 49 | public function buildLegacyCourseFromMoodleArchive(string $archivePath): object |
||
| 50 | { |
||
| 51 | // Extract Moodle backup in a temp working directory |
||
| 52 | [$workDir] = $this->extractToTemp($archivePath); |
||
| 53 | |||
| 54 | $mbx = $workDir.'/moodle_backup.xml'; |
||
| 55 | if (!is_file($mbx)) { |
||
| 56 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 57 | } |
||
| 58 | |||
| 59 | // Optional files.xml (used for documents/resources restore) |
||
| 60 | $fx = $workDir.'/files.xml'; |
||
| 61 | $fileIndex = is_file($fx) ? $this->buildFileIndex($fx, $workDir) : ['byId' => [], 'byHash' => []]; |
||
| 62 | |||
| 63 | // Read backup structure (sections + activities) |
||
| 64 | $mbDoc = $this->loadXml($mbx); |
||
| 65 | $mb = new DOMXPath($mbDoc); |
||
| 66 | |||
| 67 | $sections = $this->readSections($mb); |
||
| 68 | $lpMap = $this->sectionsToLearnpaths($sections); |
||
| 69 | |||
| 70 | // Initialize resource buckets (legacy snapshot shape) |
||
| 71 | $resources = [ |
||
| 72 | 'document' => [], |
||
| 73 | 'Forum_Category' => [], |
||
| 74 | 'forum' => [], |
||
| 75 | 'link' => [], |
||
| 76 | // 'Link_Category' / 'learnpath' / 'scorm' will be created on demand |
||
| 77 | ]; |
||
| 78 | |||
| 79 | // Ensure document folder structure |
||
| 80 | $this->ensureDir($workDir.'/document'); |
||
| 81 | $this->ensureDir($workDir.'/document/moodle_pages'); |
||
| 82 | |||
| 83 | // Root folder as a legacy "document" entry (folder) |
||
| 84 | $docFolderId = $this->nextId($resources['document']); |
||
| 85 | $resources['document'][$docFolderId] = $this->mkLegacyItem( |
||
| 86 | 'document', |
||
| 87 | $docFolderId, |
||
| 88 | [ |
||
| 89 | 'file_type' => 'folder', |
||
| 90 | 'path' => '/document/moodle_pages', |
||
| 91 | 'title' => 'moodle_pages', |
||
| 92 | ] |
||
| 93 | ); |
||
| 94 | |||
| 95 | // Default forum category (used as fallback) |
||
| 96 | $defaultForumCatId = 1; |
||
| 97 | $resources['Forum_Category'][$defaultForumCatId] = $this->mkLegacyItem( |
||
| 98 | 'Forum_Category', |
||
| 99 | $defaultForumCatId, |
||
| 100 | [ |
||
| 101 | 'id' => $defaultForumCatId, |
||
| 102 | 'cat_title' => 'General', |
||
| 103 | 'cat_comment' => '', |
||
| 104 | ] |
||
| 105 | ); |
||
| 106 | |||
| 107 | // Iterate Moodle activities |
||
| 108 | foreach ($mb->query('//activity') as $node) { |
||
| 109 | /** @var DOMElement $node */ |
||
| 110 | $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); |
||
| 111 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 112 | $sectionId = (int) ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); |
||
| 113 | $moduleXml = ('' !== $modName && '' !== $dir) ? $workDir.'/'.$dir.'/'.$modName.'.xml' : null; |
||
| 114 | |||
| 115 | if ($this->debug) { |
||
| 116 | error_log("MOODLE_IMPORT: activity={$modName} dir={$dir} section={$sectionId}"); |
||
| 117 | } |
||
| 118 | |||
| 119 | switch ($modName) { |
||
| 120 | case 'label': |
||
| 121 | case 'page': |
||
| 122 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 123 | break; |
||
| 124 | } |
||
| 125 | $data = $this->readHtmlModule($moduleXml, $modName); |
||
| 126 | |||
| 127 | // Dump HTML content into /document/moodle_pages |
||
| 128 | $docId = $this->nextId($resources['document']); |
||
| 129 | $slug = $data['slug'] ?: ('page_'.$docId); |
||
| 130 | $rel = 'document/moodle_pages/'.$slug.'.html'; |
||
| 131 | $abs = $workDir.'/'.$rel; |
||
| 132 | $this->ensureDir(\dirname($abs)); |
||
| 133 | $html = $this->wrapHtmlIfNeeded($data['content'] ?? '', $data['name'] ?? ucfirst($modName)); |
||
| 134 | file_put_contents($abs, $html); |
||
| 135 | |||
| 136 | // Legacy document entry (file) |
||
| 137 | $resources['document'][$docId] = $this->mkLegacyItem( |
||
| 138 | 'document', |
||
| 139 | $docId, |
||
| 140 | [ |
||
| 141 | 'file_type' => 'file', |
||
| 142 | 'path' => '/'.$rel, |
||
| 143 | 'title' => (string) ($data['name'] ?? ucfirst($modName)), |
||
| 144 | 'size' => @filesize($abs) ?: 0, |
||
| 145 | 'comment' => '', |
||
| 146 | ] |
||
| 147 | ); |
||
| 148 | |||
| 149 | // Add to LP if section map exists |
||
| 150 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 151 | $lpMap[$sectionId]['items'][] = [ |
||
| 152 | 'item_type' => 'document', |
||
| 153 | 'ref' => $docId, |
||
| 154 | 'title' => $data['name'] ?? ucfirst($modName), |
||
| 155 | ]; |
||
| 156 | } |
||
| 157 | |||
| 158 | break; |
||
| 159 | |||
| 160 | // Forums (+categories from intro hints) |
||
| 161 | case 'forum': |
||
| 162 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 163 | break; |
||
| 164 | } |
||
| 165 | $f = $this->readForumModule($moduleXml); |
||
| 166 | |||
| 167 | $resources['forum'] ??= []; |
||
| 168 | $resources['Forum_Category'] ??= []; |
||
| 169 | |||
| 170 | $catId = (int) ($f['category_id'] ?? 0); |
||
| 171 | $catTitle = (string) ($f['category_title'] ?? ''); |
||
| 172 | |||
| 173 | // Create Forum_Category if Moodle intro provided hints |
||
| 174 | if ($catId > 0 && !isset($resources['Forum_Category'][$catId])) { |
||
| 175 | $resources['Forum_Category'][$catId] = $this->mkLegacyItem( |
||
| 176 | 'Forum_Category', |
||
| 177 | $catId, |
||
| 178 | [ |
||
| 179 | 'id' => $catId, |
||
| 180 | 'cat_title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), |
||
| 181 | 'cat_comment' => '', |
||
| 182 | 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), |
||
| 183 | 'description' => '', |
||
| 184 | ] |
||
| 185 | ); |
||
| 186 | } |
||
| 187 | |||
| 188 | // Forum entry pointing to detected category or fallback |
||
| 189 | $dstCatId = $catId > 0 ? $catId : $defaultForumCatId; |
||
| 190 | $fid = $this->nextId($resources['forum']); |
||
| 191 | $resources['forum'][$fid] = $this->mkLegacyItem( |
||
| 192 | 'forum', |
||
| 193 | $fid, |
||
| 194 | [ |
||
| 195 | 'id' => $fid, |
||
| 196 | 'forum_title' => (string) ($f['name'] ?? 'Forum'), |
||
| 197 | 'forum_comment' => (string) ($f['description'] ?? ''), |
||
| 198 | 'forum_category' => $dstCatId, |
||
| 199 | 'default_view' => 'flat', |
||
| 200 | ] |
||
| 201 | ); |
||
| 202 | |||
| 203 | // Add to LP if section map exists |
||
| 204 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 205 | $lpMap[$sectionId]['items'][] = [ |
||
| 206 | 'item_type' => 'forum', |
||
| 207 | 'ref' => $fid, |
||
| 208 | 'title' => $f['name'] ?? 'Forum', |
||
| 209 | ]; |
||
| 210 | } |
||
| 211 | |||
| 212 | break; |
||
| 213 | |||
| 214 | // URL => link (+ Link_Category from intro hints) |
||
| 215 | case 'url': |
||
| 216 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 217 | break; |
||
| 218 | } |
||
| 219 | $u = $this->readUrlModule($moduleXml); |
||
| 220 | |||
| 221 | $urlVal = trim((string) ($u['url'] ?? '')); |
||
| 222 | if ('' === $urlVal) { |
||
| 223 | break; |
||
| 224 | } |
||
| 225 | |||
| 226 | $resources['link'] ??= []; |
||
| 227 | $resources['Link_Category'] ??= []; |
||
| 228 | |||
| 229 | $catId = (int) ($u['category_id'] ?? 0); |
||
| 230 | $catTitle = (string) ($u['category_title'] ?? ''); |
||
| 231 | if ($catId > 0 && !isset($resources['Link_Category'][$catId])) { |
||
| 232 | $resources['Link_Category'][$catId] = $this->mkLegacyItem( |
||
| 233 | 'Link_Category', |
||
| 234 | $catId, |
||
| 235 | [ |
||
| 236 | 'id' => $catId, |
||
| 237 | 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), |
||
| 238 | 'description' => '', |
||
| 239 | 'category_title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), |
||
| 240 | ] |
||
| 241 | ); |
||
| 242 | } |
||
| 243 | |||
| 244 | $lid = $this->nextId($resources['link']); |
||
| 245 | $linkTitle = ($u['name'] ?? '') !== '' ? (string) $u['name'] : $urlVal; |
||
| 246 | |||
| 247 | $resources['link'][$lid] = $this->mkLegacyItem( |
||
| 248 | 'link', |
||
| 249 | $lid, |
||
| 250 | [ |
||
| 251 | 'id' => $lid, |
||
| 252 | 'title' => $linkTitle, |
||
| 253 | 'description' => '', |
||
| 254 | 'url' => $urlVal, |
||
| 255 | 'target' => '', |
||
| 256 | 'category_id' => $catId, |
||
| 257 | 'on_homepage' => false, |
||
| 258 | ] |
||
| 259 | ); |
||
| 260 | |||
| 261 | break; |
||
| 262 | |||
| 263 | // SCORM |
||
| 264 | case 'scorm': |
||
| 265 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 266 | break; |
||
| 267 | } |
||
| 268 | $scorm = $this->readScormModule($moduleXml); |
||
| 269 | $resources['scorm'] ??= []; |
||
| 270 | |||
| 271 | $sid = $this->nextId($resources['scorm']); |
||
| 272 | $resources['scorm'][$sid] = $this->mkLegacyItem( |
||
| 273 | 'scorm', |
||
| 274 | $sid, |
||
| 275 | [ |
||
| 276 | 'id' => $sid, |
||
| 277 | 'title' => (string) ($scorm['name'] ?? 'SCORM package'), |
||
| 278 | ] |
||
| 279 | ); |
||
| 280 | |||
| 281 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 282 | $lpMap[$sectionId]['items'][] = [ |
||
| 283 | 'item_type' => 'scorm', |
||
| 284 | 'ref' => $sid, |
||
| 285 | 'title' => $scorm['name'] ?? 'SCORM package', |
||
| 286 | ]; |
||
| 287 | } |
||
| 288 | |||
| 289 | break; |
||
| 290 | |||
| 291 | default: |
||
| 292 | if ($this->debug) { |
||
| 293 | error_log("MOODLE_IMPORT: unhandled module {$modName}"); |
||
| 294 | } |
||
| 295 | |||
| 296 | break; |
||
| 297 | } |
||
| 298 | } |
||
| 299 | |||
| 300 | // Read Documents and Resource files using files.xml + activities/resource |
||
| 301 | $this->readDocuments($workDir, $mb, $fileIndex, $resources, $lpMap); |
||
| 302 | |||
| 303 | // Build learnpaths (one per section) with linked resources map |
||
| 304 | if (!empty($lpMap)) { |
||
| 305 | $resources['learnpath'] ??= []; |
||
| 306 | foreach ($lpMap as $sid => $lp) { |
||
| 307 | $linked = $this->collectLinkedFromLpItems($lp['items']); |
||
| 308 | |||
| 309 | $lid = $this->nextId($resources['learnpath']); |
||
| 310 | $resources['learnpath'][$lid] = $this->mkLegacyItem( |
||
| 311 | 'learnpath', |
||
| 312 | $lid, |
||
| 313 | [ |
||
| 314 | 'id' => $lid, |
||
| 315 | 'name' => (string) $lp['title'], |
||
| 316 | ], |
||
| 317 | ['items', 'linked_resources'] |
||
| 318 | ); |
||
| 319 | $resources['learnpath'][$lid]->items = array_map( |
||
| 320 | static fn (array $i) => [ |
||
| 321 | 'item_type' => (string) $i['item_type'], |
||
| 322 | 'title' => (string) $i['title'], |
||
| 323 | 'path' => '', |
||
| 324 | 'ref' => $i['ref'] ?? null, |
||
| 325 | ], |
||
| 326 | $lp['items'] |
||
| 327 | ); |
||
| 328 | $resources['learnpath'][$lid]->linked_resources = $linked; |
||
| 329 | } |
||
| 330 | } |
||
| 331 | |||
| 332 | // Compose Course snapshot |
||
| 333 | $course = new Course(); |
||
| 334 | $course->resources = $resources; |
||
| 335 | $course->backup_path = $workDir; |
||
| 336 | |||
| 337 | // Meta: keep a stable place (Course::$meta) and optionally mirror into resources['__meta'] |
||
| 338 | $course->meta = [ |
||
| 339 | 'import_source' => 'moodle', |
||
| 340 | 'generated_at' => date('c'), |
||
| 341 | ]; |
||
| 342 | $course->resources['__meta'] = $course->meta; // if you prefer not to iterate over this, skip it in your loops |
||
| 343 | |||
| 344 | // Basic course info (optional) |
||
| 345 | $ci = \function_exists('api_get_course_info') ? (api_get_course_info() ?: []) : []; |
||
| 346 | if (property_exists($course, 'code')) { |
||
| 347 | $course->code = (string) ($ci['code'] ?? ''); |
||
| 348 | } |
||
| 349 | if (property_exists($course, 'type')) { |
||
| 350 | $course->type = 'partial'; |
||
| 351 | } |
||
| 352 | if (property_exists($course, 'encoding')) { |
||
| 353 | $course->encoding = \function_exists('api_get_system_encoding') |
||
| 354 | ? api_get_system_encoding() |
||
| 355 | : 'UTF-8'; |
||
| 356 | } |
||
| 357 | |||
| 358 | if ($this->debug) { |
||
| 359 | error_log('MOODLE_IMPORT: resources='.json_encode( |
||
| 360 | array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources), |
||
| 361 | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES |
||
| 362 | )); |
||
| 363 | error_log('MOODLE_IMPORT: backup_path='.$course->backup_path); |
||
| 364 | if (property_exists($course, 'code') && property_exists($course, 'encoding')) { |
||
| 365 | error_log('MOODLE_IMPORT: course_code='.$course->code.' encoding='.$course->encoding); |
||
| 366 | } |
||
| 367 | } |
||
| 368 | |||
| 369 | return $course; |
||
| 370 | } |
||
| 371 | |||
| 372 | private function extractToTemp(string $archivePath): array |
||
| 373 | { |
||
| 374 | $base = rtrim(sys_get_temp_dir(), '/').'/moodle_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)); |
||
| 375 | if (!@mkdir($base, 0775, true)) { |
||
| 376 | throw new RuntimeException('Cannot create temp dir'); |
||
| 377 | } |
||
| 378 | |||
| 379 | $ext = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION)); |
||
| 380 | if (\in_array($ext, ['zip', 'mbz'], true)) { |
||
| 381 | $zip = new ZipArchive(); |
||
| 382 | if (true !== $zip->open($archivePath)) { |
||
| 383 | throw new RuntimeException('Cannot open zip'); |
||
| 384 | } |
||
| 385 | if (!$zip->extractTo($base)) { |
||
| 386 | $zip->close(); |
||
| 387 | |||
| 388 | throw new RuntimeException('Cannot extract zip'); |
||
| 389 | } |
||
| 390 | $zip->close(); |
||
| 391 | } elseif (\in_array($ext, ['gz', 'tgz'], true)) { |
||
| 392 | $phar = new PharData($archivePath); |
||
| 393 | $phar->extractTo($base, null, true); |
||
| 394 | } else { |
||
| 395 | throw new RuntimeException('Unsupported archive type'); |
||
| 396 | } |
||
| 397 | |||
| 398 | if (!is_file($base.'/moodle_backup.xml')) { |
||
| 399 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 400 | } |
||
| 401 | |||
| 402 | return [$base]; |
||
| 403 | } |
||
| 404 | |||
| 405 | private function loadXml(string $path): DOMDocument |
||
| 406 | { |
||
| 407 | $xml = @file_get_contents($path); |
||
| 408 | if (false === $xml || '' === $xml) { |
||
| 409 | throw new RuntimeException('Cannot read XML: '.$path); |
||
| 410 | } |
||
| 411 | $doc = new DOMDocument(); |
||
| 412 | $doc->preserveWhiteSpace = false; |
||
| 413 | if (!@$doc->loadXML($xml)) { |
||
| 414 | throw new RuntimeException('Invalid XML: '.$path); |
||
| 415 | } |
||
| 416 | |||
| 417 | return $doc; |
||
| 418 | } |
||
| 419 | |||
| 420 | /** |
||
| 421 | * Build an index from files.xml. |
||
| 422 | * Returns ['byId' => [id => row], 'byHash' => [hash => row]]. |
||
| 423 | * Each row contains: id, hash, filename, filepath, component, filearea, mimetype, filesize, contextid, blob(abs path). |
||
| 424 | */ |
||
| 425 | private function buildFileIndex(string $filesXmlPath, string $workDir): array |
||
| 426 | { |
||
| 427 | $doc = $this->loadXml($filesXmlPath); |
||
| 428 | $xp = new DOMXPath($doc); |
||
| 429 | |||
| 430 | $byId = []; |
||
| 431 | $byHash = []; |
||
| 432 | |||
| 433 | foreach ($xp->query('//file') as $f) { |
||
| 434 | /** @var DOMElement $f */ |
||
| 435 | $id = (int) ($f->getAttribute('id') ?? 0); |
||
| 436 | $hash = (string) ($f->getElementsByTagName('contenthash')->item(0)?->nodeValue ?? ''); |
||
| 437 | if ('' === $hash) { |
||
| 438 | continue; |
||
| 439 | } |
||
| 440 | |||
| 441 | $name = (string) ($f->getElementsByTagName('filename')->item(0)?->nodeValue ?? ''); |
||
| 442 | $fp = (string) ($f->getElementsByTagName('filepath')->item(0)?->nodeValue ?? '/'); |
||
| 443 | $comp = (string) ($f->getElementsByTagName('component')->item(0)?->nodeValue ?? ''); |
||
| 444 | $fa = (string) ($f->getElementsByTagName('filearea')->item(0)?->nodeValue ?? ''); |
||
| 445 | $mime = (string) ($f->getElementsByTagName('mimetype')->item(0)?->nodeValue ?? ''); |
||
| 446 | $size = (int) ($f->getElementsByTagName('filesize')->item(0)?->nodeValue ?? 0); |
||
| 447 | $ctx = (int) ($f->getElementsByTagName('contextid')->item(0)?->nodeValue ?? 0); |
||
| 448 | |||
| 449 | $blob = $this->contentHashPath($workDir, $hash); |
||
| 450 | |||
| 451 | $row = [ |
||
| 452 | 'id' => $id, |
||
| 453 | 'hash' => $hash, |
||
| 454 | 'filename' => $name, |
||
| 455 | 'filepath' => $fp, |
||
| 456 | 'component' => $comp, |
||
| 457 | 'filearea' => $fa, |
||
| 458 | 'mimetype' => $mime, |
||
| 459 | 'filesize' => $size, |
||
| 460 | 'contextid' => $ctx, |
||
| 461 | 'blob' => $blob, |
||
| 462 | ]; |
||
| 463 | |||
| 464 | if ($id > 0) { |
||
| 465 | $byId[$id] = $row; |
||
| 466 | } |
||
| 467 | $byHash[$hash] = $row; |
||
| 468 | } |
||
| 469 | |||
| 470 | return ['byId' => $byId, 'byHash' => $byHash]; |
||
| 471 | } |
||
| 472 | |||
| 473 | private function readSections(DOMXPath $xp): array |
||
| 474 | { |
||
| 475 | $out = []; |
||
| 476 | foreach ($xp->query('//section') as $s) { |
||
| 477 | /** @var DOMElement $s */ |
||
| 478 | $id = (int) ($s->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); |
||
| 479 | if ($id <= 0) { |
||
| 480 | $id = (int) ($s->getElementsByTagName('number')->item(0)?->nodeValue |
||
| 481 | ?? $s->getElementsByTagName('id')->item(0)?->nodeValue |
||
| 482 | ?? 0); |
||
| 483 | } |
||
| 484 | $name = (string) ($s->getElementsByTagName('name')->item(0)?->nodeValue ?? ''); |
||
| 485 | $summary = (string) ($s->getElementsByTagName('summary')->item(0)?->nodeValue ?? ''); |
||
| 486 | if ($id > 0) { |
||
| 487 | $out[$id] = ['id' => $id, 'name' => $name, 'summary' => $summary]; |
||
| 488 | } |
||
| 489 | } |
||
| 490 | |||
| 491 | return $out; |
||
| 492 | } |
||
| 493 | |||
| 494 | private function sectionsToLearnpaths(array $sections): array |
||
| 495 | { |
||
| 496 | $map = []; |
||
| 497 | foreach ($sections as $sid => $s) { |
||
| 498 | $title = $s['name'] ?: ('Section '.$sid); |
||
| 499 | $map[(int) $sid] = [ |
||
| 500 | 'title' => $title, |
||
| 501 | 'items' => [], |
||
| 502 | ]; |
||
| 503 | } |
||
| 504 | |||
| 505 | return $map; |
||
| 506 | } |
||
| 507 | |||
| 508 | private function readHtmlModule(string $xmlPath, string $type): array |
||
| 509 | { |
||
| 510 | $doc = $this->loadXml($xmlPath); |
||
| 511 | $xp = new DOMXPath($doc); |
||
| 512 | |||
| 513 | $name = (string) ($xp->query('//name')->item(0)?->nodeValue ?? ucfirst($type)); |
||
| 514 | |||
| 515 | $content = (string) ($xp->query('//intro')->item(0)?->nodeValue |
||
| 516 | ?? $xp->query('//content')->item(0)?->nodeValue |
||
| 517 | ?? ''); |
||
| 518 | |||
| 519 | return [ |
||
| 520 | 'name' => $name, |
||
| 521 | 'content' => $content, |
||
| 522 | 'slug' => $this->slugify($name), |
||
| 523 | ]; |
||
| 524 | } |
||
| 525 | |||
| 526 | private function readForumModule(string $xmlPath): array |
||
| 527 | { |
||
| 528 | $doc = $this->loadXml($xmlPath); |
||
| 529 | $xp = new DOMXPath($doc); |
||
| 530 | |||
| 531 | $name = trim((string) ($xp->query('//forum/name')->item(0)?->nodeValue ?? '')); |
||
| 532 | $description = (string) ($xp->query('//forum/intro')->item(0)?->nodeValue ?? ''); |
||
| 533 | $type = trim((string) ($xp->query('//forum/type')->item(0)?->nodeValue ?? 'general')); |
||
| 534 | |||
| 535 | $catId = 0; |
||
| 536 | $catTitle = ''; |
||
| 537 | if (preg_match('/CHAMILO2:forum_category_id:(\d+)/', $description, $m)) { |
||
| 538 | $catId = (int) $m[1]; |
||
| 539 | } |
||
| 540 | if (preg_match('/CHAMILO2:forum_category_title:([^\-]+?)\s*-->/u', $description, $m)) { |
||
| 541 | $catTitle = trim($m[1]); |
||
| 542 | } |
||
| 543 | |||
| 544 | return [ |
||
| 545 | 'name' => ('' !== $name ? $name : 'Forum'), |
||
| 546 | 'description' => $description, |
||
| 547 | 'type' => ('' !== $type ? $type : 'general'), |
||
| 548 | 'category_id' => $catId, |
||
| 549 | 'category_title' => $catTitle, |
||
| 550 | ]; |
||
| 551 | } |
||
| 552 | |||
| 553 | private function readUrlModule(string $xmlPath): array |
||
| 554 | { |
||
| 555 | $doc = $this->loadXml($xmlPath); |
||
| 556 | $xp = new DOMXPath($doc); |
||
| 557 | $name = trim($xp->query('//url/name')->item(0)?->nodeValue ?? ''); |
||
| 558 | $url = trim($xp->query('//url/externalurl')->item(0)?->nodeValue ?? ''); |
||
| 559 | $intro = (string) ($xp->query('//url/intro')->item(0)?->nodeValue ?? ''); |
||
| 560 | |||
| 561 | $catId = 0; |
||
| 562 | $catTitle = ''; |
||
| 563 | if (preg_match('/CHAMILO2:link_category_id:(\d+)/', $intro, $m)) { |
||
| 564 | $catId = (int) $m[1]; |
||
| 565 | } |
||
| 566 | if (preg_match('/CHAMILO2:link_category_title:([^\-]+?)\s*-->/u', $intro, $m)) { |
||
| 567 | $catTitle = trim($m[1]); |
||
| 568 | } |
||
| 569 | |||
| 570 | return ['name' => $name, 'url' => $url, 'category_id' => $catId, 'category_title' => $catTitle]; |
||
| 571 | } |
||
| 572 | |||
| 573 | private function readScormModule(string $xmlPath): array |
||
| 574 | { |
||
| 575 | $doc = $this->loadXml($xmlPath); |
||
| 576 | $xp = new DOMXPath($doc); |
||
| 577 | |||
| 578 | return [ |
||
| 579 | 'name' => (string) ($xp->query('//name')->item(0)?->nodeValue ?? 'SCORM'), |
||
| 580 | ]; |
||
| 581 | } |
||
| 582 | |||
| 583 | private function collectLinkedFromLpItems(array $items): array |
||
| 584 | { |
||
| 585 | $map = [ |
||
| 586 | 'document' => 'document', |
||
| 587 | 'forum' => 'forum', |
||
| 588 | 'url' => 'link', |
||
| 589 | 'link' => 'link', |
||
| 590 | 'weblink' => 'link', |
||
| 591 | 'work' => 'works', |
||
| 592 | 'student_publication' => 'works', |
||
| 593 | 'quiz' => 'quiz', |
||
| 594 | 'exercise' => 'quiz', |
||
| 595 | 'survey' => 'survey', |
||
| 596 | 'scorm' => 'scorm', |
||
| 597 | ]; |
||
| 598 | |||
| 599 | $out = []; |
||
| 600 | foreach ($items as $i) { |
||
| 601 | $t = (string) ($i['item_type'] ?? ''); |
||
| 602 | $r = $i['ref'] ?? null; |
||
| 603 | if ('' === $t || null === $r) { |
||
| 604 | continue; |
||
| 605 | } |
||
| 606 | $bag = $map[$t] ?? $t; |
||
| 607 | $out[$bag] ??= []; |
||
| 608 | $out[$bag][] = (int) $r; |
||
| 609 | } |
||
| 610 | |||
| 611 | return $out; |
||
| 612 | } |
||
| 613 | |||
| 614 | private function nextId(array $bucket): int |
||
| 615 | { |
||
| 616 | $max = 0; |
||
| 617 | foreach ($bucket as $k => $_) { |
||
| 618 | $i = is_numeric($k) ? (int) $k : 0; |
||
| 619 | if ($i > $max) { |
||
| 620 | $max = $i; |
||
| 621 | } |
||
| 622 | } |
||
| 623 | |||
| 624 | return $max + 1; |
||
| 625 | } |
||
| 626 | |||
| 627 | private function slugify(string $s): string |
||
| 628 | { |
||
| 629 | $t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s); |
||
| 630 | $t = strtolower(preg_replace('/[^a-z0-9]+/', '-', $t ?: $s)); |
||
| 631 | |||
| 632 | return trim($t, '-') ?: 'item'; |
||
| 633 | } |
||
| 634 | |||
| 635 | private function wrapHtmlIfNeeded(string $content, string $title = 'Page'): string |
||
| 636 | { |
||
| 637 | $trim = ltrim($content); |
||
| 638 | $looksHtml = str_contains(strtolower(substr($trim, 0, 200)), '<html') |
||
| 639 | || str_contains(strtolower(substr($trim, 0, 200)), '<!doctype'); |
||
| 640 | |||
| 641 | if ($looksHtml) { |
||
| 642 | return $content; |
||
| 643 | } |
||
| 644 | |||
| 645 | return "<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>". |
||
| 646 | htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). |
||
| 647 | "</title></head><body>\n".$content."\n</body></html>"; |
||
| 648 | } |
||
| 649 | |||
| 650 | private function ensureDir(string $dir): void |
||
| 651 | { |
||
| 652 | if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { |
||
| 653 | throw new RuntimeException('Cannot create directory: '.$dir); |
||
| 654 | } |
||
| 655 | } |
||
| 656 | |||
| 657 | /** |
||
| 658 | * Resolve physical path for a given contenthash. |
||
| 659 | * Our exporter writes blobs in: files/<first two letters of hash>/<hash>. |
||
| 660 | */ |
||
| 661 | private function contentHashPath(string $workDir, string $hash): string |
||
| 662 | { |
||
| 663 | $h = trim($hash); |
||
| 664 | if ('' === $h || \strlen($h) < 2) { |
||
| 665 | return $workDir.'/files/'.$h; |
||
| 666 | } |
||
| 667 | |||
| 668 | // export convention: files/<two first letters>/<full-hash> |
||
| 669 | return $workDir.'/files/'.substr($h, 0, 2).'/'.$h; |
||
| 670 | } |
||
| 671 | |||
| 672 | /** |
||
| 673 | * Fast-path: persist only Links (and Link Categories) from a Moodle backup |
||
| 674 | * directly with Doctrine entities. This bypasses the generic Restorer so we |
||
| 675 | * avoid ResourceType#tool and UserAuthSource#url cascade issues. |
||
| 676 | * |
||
| 677 | * @return array{categories:int,links:int} |
||
| 678 | */ |
||
| 679 | public function restoreLinks( |
||
| 680 | string $archivePath, |
||
| 681 | EntityManagerInterface $em, |
||
| 682 | int $courseRealId, |
||
| 683 | int $sessionId = 0, |
||
| 684 | ?object $courseArg = null |
||
| 685 | ): array { |
||
| 686 | // Resolve parent entities |
||
| 687 | /** @var CourseEntity|null $course */ |
||
| 688 | $course = $em->getRepository(CourseEntity::class)->find($courseRealId); |
||
| 689 | if (!$course) { |
||
| 690 | throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')'); |
||
| 691 | } |
||
| 692 | |||
| 693 | /** @var SessionEntity|null $session */ |
||
| 694 | $session = $sessionId > 0 |
||
| 695 | ? $em->getRepository(SessionEntity::class)->find($sessionId) |
||
| 696 | : null; |
||
| 697 | |||
| 698 | // Fast-path: use filtered snapshot if provided (import/resources selection) |
||
| 699 | if ($courseArg && isset($courseArg->resources) && \is_array($courseArg->resources)) { |
||
| 700 | $linksBucket = (array) ($courseArg->resources['link'] ?? []); |
||
| 701 | $catsBucket = (array) ($courseArg->resources['Link_Category'] ?? []); |
||
| 702 | |||
| 703 | if (empty($linksBucket)) { |
||
| 704 | if ($this->debug) { |
||
| 705 | error_log('MOODLE_IMPORT[restoreLinks]: snapshot has no selected links'); |
||
| 706 | } |
||
| 707 | |||
| 708 | return ['categories' => 0, 'links' => 0]; |
||
| 709 | } |
||
| 710 | |||
| 711 | // Build set of category ids actually referenced by selected links |
||
| 712 | $usedCatIds = []; |
||
| 713 | foreach ($linksBucket as $L) { |
||
| 714 | $oldCatId = (int) ($L->category_id ?? 0); |
||
| 715 | if ($oldCatId > 0) { |
||
| 716 | $usedCatIds[$oldCatId] = true; |
||
| 717 | } |
||
| 718 | } |
||
| 719 | |||
| 720 | // Persist only needed categories |
||
| 721 | $catMapByOldId = []; |
||
| 722 | $newCats = 0; |
||
| 723 | |||
| 724 | foreach ($catsBucket as $oldId => $C) { |
||
| 725 | if (!isset($usedCatIds[$oldId])) { |
||
| 726 | continue; |
||
| 727 | } |
||
| 728 | |||
| 729 | $cat = (new CLinkCategory()) |
||
| 730 | ->setTitle((string) ($C->title ?? ('Category '.$oldId))) |
||
| 731 | ->setDescription((string) ($C->description ?? '')) |
||
| 732 | ; |
||
| 733 | |||
| 734 | // Parent & course/session links BEFORE persist (prePersist needs a parent) |
||
| 735 | if (method_exists($cat, 'setParent')) { |
||
| 736 | $cat->setParent($course); |
||
| 737 | } elseif (method_exists($cat, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 738 | $cat->setParentResourceNode($course->getResourceNode()); |
||
| 739 | } |
||
| 740 | if (method_exists($cat, 'addCourseLink')) { |
||
| 741 | $cat->addCourseLink($course, $session); |
||
| 742 | } |
||
| 743 | |||
| 744 | $em->persist($cat); |
||
| 745 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 746 | $newCats++; |
||
| 747 | } |
||
| 748 | if ($newCats > 0) { |
||
| 749 | $em->flush(); |
||
| 750 | } |
||
| 751 | |||
| 752 | // Persist selected links |
||
| 753 | $newLinks = 0; |
||
| 754 | foreach ($linksBucket as $L) { |
||
| 755 | $url = trim((string) ($L->url ?? '')); |
||
| 756 | if ('' === $url) { |
||
| 757 | continue; |
||
| 758 | } |
||
| 759 | |||
| 760 | $title = (string) ($L->title ?? ''); |
||
| 761 | if ('' === $title) { |
||
| 762 | $title = $url; |
||
| 763 | } |
||
| 764 | |||
| 765 | $link = (new CLink()) |
||
| 766 | ->setUrl($url) |
||
| 767 | ->setTitle($title) |
||
| 768 | ->setDescription((string) ($L->description ?? '')) |
||
| 769 | ->setTarget((string) ($L->target ?? '')) |
||
| 770 | ; |
||
| 771 | |||
| 772 | if (method_exists($link, 'setParent')) { |
||
| 773 | $link->setParent($course); |
||
| 774 | } elseif (method_exists($link, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 775 | $link->setParentResourceNode($course->getResourceNode()); |
||
| 776 | } |
||
| 777 | if (method_exists($link, 'addCourseLink')) { |
||
| 778 | $link->addCourseLink($course, $session); |
||
| 779 | } |
||
| 780 | |||
| 781 | $oldCatId = (int) ($L->category_id ?? 0); |
||
| 782 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 783 | $link->setCategory($catMapByOldId[$oldCatId]); |
||
| 784 | } |
||
| 785 | |||
| 786 | $em->persist($link); |
||
| 787 | $newLinks++; |
||
| 788 | } |
||
| 789 | |||
| 790 | $em->flush(); |
||
| 791 | |||
| 792 | if ($this->debug) { |
||
| 793 | error_log('MOODLE_IMPORT[restoreLinks]: persisted (snapshot)='. |
||
| 794 | json_encode(['cats' => $newCats, 'links' => $newLinks], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
||
| 795 | } |
||
| 796 | |||
| 797 | return ['categories' => $newCats, 'links' => $newLinks]; |
||
| 798 | } |
||
| 799 | |||
| 800 | // Extract & open main XML |
||
| 801 | [$workDir] = $this->extractToTemp($archivePath); |
||
| 802 | |||
| 803 | $mbx = $workDir.'/moodle_backup.xml'; |
||
| 804 | if (!is_file($mbx)) { |
||
| 805 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 806 | } |
||
| 807 | $mbDoc = $this->loadXml($mbx); |
||
| 808 | $mb = new DOMXPath($mbDoc); |
||
| 809 | |||
| 810 | // Collect URL activities -> { name, url, category hints } |
||
| 811 | $links = []; |
||
| 812 | $categories = []; // oldCatId => ['title' => ...] |
||
| 813 | foreach ($mb->query('//activity') as $node) { |
||
| 814 | /** @var DOMElement $node */ |
||
| 815 | $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); |
||
| 816 | if ('url' !== $modName) { |
||
| 817 | continue; |
||
| 818 | } |
||
| 819 | |||
| 820 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 821 | $moduleXml = ('' !== $dir) ? $workDir.'/'.$dir.'/url.xml' : null; |
||
| 822 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 823 | if ($this->debug) { |
||
| 824 | error_log('MOODLE_IMPORT[restoreLinks]: skip url (url.xml not found)'); |
||
| 825 | } |
||
| 826 | |||
| 827 | continue; |
||
| 828 | } |
||
| 829 | |||
| 830 | $u = $this->readUrlModule($moduleXml); |
||
| 831 | |||
| 832 | $urlVal = trim((string) ($u['url'] ?? '')); |
||
| 833 | if ('' === $urlVal) { |
||
| 834 | if ($this->debug) { |
||
| 835 | error_log('MOODLE_IMPORT[restoreLinks]: skip url (empty externalurl)'); |
||
| 836 | } |
||
| 837 | |||
| 838 | continue; |
||
| 839 | } |
||
| 840 | |||
| 841 | $oldCatId = (int) ($u['category_id'] ?? 0); |
||
| 842 | $oldCatTitle = (string) ($u['category_title'] ?? ''); |
||
| 843 | if ($oldCatId > 0 && !isset($categories[$oldCatId])) { |
||
| 844 | $categories[$oldCatId] = [ |
||
| 845 | 'title' => ('' !== $oldCatTitle ? $oldCatTitle : ('Category '.$oldCatId)), |
||
| 846 | 'description' => '', |
||
| 847 | ]; |
||
| 848 | } |
||
| 849 | |||
| 850 | $links[] = [ |
||
| 851 | 'name' => (string) ($u['name'] ?? ''), |
||
| 852 | 'url' => $urlVal, |
||
| 853 | 'description' => '', |
||
| 854 | 'target' => '', |
||
| 855 | 'old_cat_id' => $oldCatId, |
||
| 856 | ]; |
||
| 857 | } |
||
| 858 | |||
| 859 | if ($this->debug) { |
||
| 860 | error_log('MOODLE_IMPORT[restoreLinks]: to_persist='. |
||
| 861 | json_encode(['cats' => \count($categories), 'links' => \count($links)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
||
| 862 | } |
||
| 863 | |||
| 864 | if (empty($links) && empty($categories)) { |
||
| 865 | return ['categories' => 0, 'links' => 0]; |
||
| 866 | } |
||
| 867 | |||
| 868 | // Helper: robustly resolve an IID as int after flush |
||
| 869 | $resolveIid = static function ($entity): int { |
||
| 870 | // try entity->getIid() |
||
| 871 | if (method_exists($entity, 'getIid')) { |
||
| 872 | $iid = $entity->getIid(); |
||
| 873 | if (\is_int($iid)) { |
||
| 874 | return $iid; |
||
| 875 | } |
||
| 876 | if (is_numeric($iid)) { |
||
| 877 | return (int) $iid; |
||
| 878 | } |
||
| 879 | } |
||
| 880 | // fallback: resource node iid |
||
| 881 | if (method_exists($entity, 'getResourceNode')) { |
||
| 882 | $node = $entity->getResourceNode(); |
||
| 883 | if ($node && method_exists($node, 'getIid')) { |
||
| 884 | $nid = $node->getIid(); |
||
| 885 | if (\is_int($nid)) { |
||
| 886 | return $nid; |
||
| 887 | } |
||
| 888 | if (is_numeric($nid)) { |
||
| 889 | return (int) $nid; |
||
| 890 | } |
||
| 891 | } |
||
| 892 | } |
||
| 893 | // last resort: primary ID |
||
| 894 | if (method_exists($entity, 'getId')) { |
||
| 895 | $id = $entity->getId(); |
||
| 896 | if (\is_int($id)) { |
||
| 897 | return $id; |
||
| 898 | } |
||
| 899 | if (is_numeric($id)) { |
||
| 900 | return (int) $id; |
||
| 901 | } |
||
| 902 | } |
||
| 903 | |||
| 904 | return 0; |
||
| 905 | }; |
||
| 906 | |||
| 907 | // Persist categories first -> flush -> refresh -> map iid |
||
| 908 | $catMapByOldId = []; // oldCatId => CLinkCategory entity |
||
| 909 | $iidMapByOldId = []; // oldCatId => int iid |
||
| 910 | $newCats = 0; |
||
| 911 | |||
| 912 | foreach ($categories as $oldId => $payload) { |
||
| 913 | $cat = (new CLinkCategory()) |
||
| 914 | ->setTitle((string) $payload['title']) |
||
| 915 | ->setDescription((string) $payload['description']) |
||
| 916 | ; |
||
| 917 | |||
| 918 | // Parent & course/session links BEFORE persist (prePersist needs a parent) |
||
| 919 | if (method_exists($cat, 'setParent')) { |
||
| 920 | $cat->setParent($course); |
||
| 921 | } elseif (method_exists($cat, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 922 | $cat->setParentResourceNode($course->getResourceNode()); |
||
| 923 | } |
||
| 924 | if (method_exists($cat, 'addCourseLink')) { |
||
| 925 | $cat->addCourseLink($course, $session); |
||
| 926 | } |
||
| 927 | |||
| 928 | $em->persist($cat); |
||
| 929 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 930 | $newCats++; |
||
| 931 | } |
||
| 932 | |||
| 933 | // Flush categories to get identifiers assigned |
||
| 934 | if ($newCats > 0) { |
||
| 935 | $em->flush(); |
||
| 936 | // Refresh & resolve iid |
||
| 937 | foreach ($catMapByOldId as $oldId => $cat) { |
||
| 938 | $em->refresh($cat); |
||
| 939 | $iidMapByOldId[$oldId] = $resolveIid($cat); |
||
| 940 | if ($this->debug) { |
||
| 941 | error_log('MOODLE_IMPORT[restoreLinks]: category persisted {old='.$oldId.', iid='.$iidMapByOldId[$oldId].', title='.$cat->getTitle().'}'); |
||
| 942 | } |
||
| 943 | } |
||
| 944 | } |
||
| 945 | |||
| 946 | // Persist links (single flush at the end) |
||
| 947 | $newLinks = 0; |
||
| 948 | foreach ($links as $L) { |
||
| 949 | $url = trim((string) $L['url']); |
||
| 950 | if ('' === $url) { |
||
| 951 | continue; |
||
| 952 | } |
||
| 953 | |||
| 954 | $title = (string) ($L['name'] ?? ''); |
||
| 955 | if ('' === $title) { |
||
| 956 | $title = $url; |
||
| 957 | } |
||
| 958 | |||
| 959 | $link = (new CLink()) |
||
| 960 | ->setUrl($url) |
||
| 961 | ->setTitle($title) |
||
| 962 | ->setDescription((string) ($L['description'] ?? '')) |
||
| 963 | ->setTarget((string) ($L['target'] ?? '')) |
||
| 964 | ; |
||
| 965 | |||
| 966 | // Parent & course/session links |
||
| 967 | if (method_exists($link, 'setParent')) { |
||
| 968 | $link->setParent($course); |
||
| 969 | } elseif (method_exists($link, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) { |
||
| 970 | $link->setParentResourceNode($course->getResourceNode()); |
||
| 971 | } |
||
| 972 | if (method_exists($link, 'addCourseLink')) { |
||
| 973 | $link->addCourseLink($course, $session); |
||
| 974 | } |
||
| 975 | |||
| 976 | // Attach category if it existed in Moodle |
||
| 977 | $oldCatId = (int) ($L['old_cat_id'] ?? 0); |
||
| 978 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 979 | $link->setCategory($catMapByOldId[$oldCatId]); |
||
| 980 | } |
||
| 981 | |||
| 982 | $em->persist($link); |
||
| 983 | $newLinks++; |
||
| 984 | } |
||
| 985 | |||
| 986 | $em->flush(); |
||
| 987 | |||
| 988 | if ($this->debug) { |
||
| 989 | error_log('MOODLE_IMPORT[restoreLinks]: persisted='. |
||
| 990 | json_encode(['cats' => $newCats, 'links' => $newLinks], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
||
| 991 | } |
||
| 992 | |||
| 993 | return ['categories' => $newCats, 'links' => $newLinks]; |
||
| 994 | } |
||
| 995 | |||
| 996 | /** |
||
| 997 | * Fast-path: persist only Forum Categories and Forums from a Moodle backup, |
||
| 998 | * wiring proper parents and course/session links with Doctrine entities. |
||
| 999 | * |
||
| 1000 | * @return array{categories:int,forums:int} |
||
| 1001 | */ |
||
| 1002 | public function restoreForums( |
||
| 1003 | string $archivePath, |
||
| 1004 | EntityManagerInterface $em, |
||
| 1005 | int $courseRealId, |
||
| 1006 | int $sessionId = 0, |
||
| 1007 | ?object $courseArg = null |
||
| 1008 | ): array { |
||
| 1009 | /** @var CourseEntity|null $course */ |
||
| 1010 | $course = $em->getRepository(CourseEntity::class)->find($courseRealId); |
||
| 1011 | if (!$course) { |
||
| 1012 | throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')'); |
||
| 1013 | } |
||
| 1014 | |||
| 1015 | /** @var SessionEntity|null $session */ |
||
| 1016 | $session = $sessionId > 0 |
||
| 1017 | ? $em->getRepository(SessionEntity::class)->find($sessionId) |
||
| 1018 | : null; |
||
| 1019 | |||
| 1020 | // Fast-path: use filtered snapshot if provided (import/resources selection) |
||
| 1021 | if ($courseArg && isset($courseArg->resources) && \is_array($courseArg->resources)) { |
||
| 1022 | $forumsBucket = (array) ($courseArg->resources['forum'] ?? []); |
||
| 1023 | $catsBucket = (array) ($courseArg->resources['Forum_Category'] ?? []); |
||
| 1024 | |||
| 1025 | if (empty($forumsBucket)) { |
||
| 1026 | if ($this->debug) { |
||
| 1027 | error_log('MOODLE_IMPORT[restoreForums]: snapshot has no selected forums'); |
||
| 1028 | } |
||
| 1029 | |||
| 1030 | return ['categories' => 0, 'forums' => 0]; |
||
| 1031 | } |
||
| 1032 | |||
| 1033 | // Categories actually referenced by selected forums |
||
| 1034 | $usedCatIds = []; |
||
| 1035 | foreach ($forumsBucket as $F) { |
||
| 1036 | $oldCatId = (int) ($F->forum_category ?? 0); |
||
| 1037 | if ($oldCatId > 0) { |
||
| 1038 | $usedCatIds[$oldCatId] = true; |
||
| 1039 | } |
||
| 1040 | } |
||
| 1041 | |||
| 1042 | // Persist only needed categories |
||
| 1043 | $catMapByOldId = []; |
||
| 1044 | $newCats = 0; |
||
| 1045 | foreach ($catsBucket as $oldId => $C) { |
||
| 1046 | if (!isset($usedCatIds[$oldId])) { |
||
| 1047 | continue; |
||
| 1048 | } |
||
| 1049 | |||
| 1050 | $cat = (new CForumCategory()) |
||
| 1051 | ->setTitle((string) ($C->cat_title ?? $C->title ?? ('Category '.$oldId))) |
||
| 1052 | ->setCatComment((string) ($C->cat_comment ?? $C->description ?? '')) |
||
| 1053 | ->setParent($course) |
||
| 1054 | ->addCourseLink($course, $session) |
||
| 1055 | ; |
||
| 1056 | $em->persist($cat); |
||
| 1057 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 1058 | $newCats++; |
||
| 1059 | } |
||
| 1060 | if ($newCats > 0) { |
||
| 1061 | $em->flush(); |
||
| 1062 | } |
||
| 1063 | |||
| 1064 | // Fallback default category if none referenced |
||
| 1065 | $defaultCat = null; |
||
| 1066 | $ensureDefault = function () use (&$defaultCat, $course, $session, $em): CForumCategory { |
||
| 1067 | if ($defaultCat instanceof CForumCategory) { |
||
| 1068 | return $defaultCat; |
||
| 1069 | } |
||
| 1070 | $defaultCat = (new CForumCategory()) |
||
| 1071 | ->setTitle('General') |
||
| 1072 | ->setCatComment('') |
||
| 1073 | ->setParent($course) |
||
| 1074 | ->addCourseLink($course, $session) |
||
| 1075 | ; |
||
| 1076 | $em->persist($defaultCat); |
||
| 1077 | $em->flush(); |
||
| 1078 | |||
| 1079 | return $defaultCat; |
||
| 1080 | }; |
||
| 1081 | |||
| 1082 | // Persist selected forums |
||
| 1083 | $newForums = 0; |
||
| 1084 | foreach ($forumsBucket as $F) { |
||
| 1085 | $title = (string) ($F->forum_title ?? $F->title ?? 'Forum'); |
||
| 1086 | $comment = (string) ($F->forum_comment ?? $F->description ?? ''); |
||
| 1087 | |||
| 1088 | $dstCategory = null; |
||
| 1089 | $oldCatId = (int) ($F->forum_category ?? 0); |
||
| 1090 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 1091 | $dstCategory = $catMapByOldId[$oldCatId]; |
||
| 1092 | } elseif (1 === \count($catMapByOldId)) { |
||
| 1093 | $dstCategory = reset($catMapByOldId); |
||
| 1094 | } else { |
||
| 1095 | $dstCategory = $ensureDefault(); |
||
| 1096 | } |
||
| 1097 | |||
| 1098 | $forum = (new CForum()) |
||
| 1099 | ->setTitle($title) |
||
| 1100 | ->setForumComment($comment) |
||
| 1101 | ->setForumCategory($dstCategory) |
||
| 1102 | ->setAllowAttachments(1) |
||
| 1103 | ->setAllowNewThreads(1) |
||
| 1104 | ->setDefaultView('flat') |
||
| 1105 | ->setParent($dstCategory) |
||
| 1106 | ->addCourseLink($course, $session) |
||
| 1107 | ; |
||
| 1108 | |||
| 1109 | $em->persist($forum); |
||
| 1110 | $newForums++; |
||
| 1111 | } |
||
| 1112 | |||
| 1113 | $em->flush(); |
||
| 1114 | |||
| 1115 | if ($this->debug) { |
||
| 1116 | error_log('MOODLE_IMPORT[restoreForums]: persisted (snapshot) cats='.$newCats.' forums='.$newForums); |
||
| 1117 | } |
||
| 1118 | |||
| 1119 | return ['categories' => $newCats + ($defaultCat ? 1 : 0), 'forums' => $newForums]; |
||
| 1120 | } |
||
| 1121 | |||
| 1122 | [$workDir] = $this->extractToTemp($archivePath); |
||
| 1123 | |||
| 1124 | $mbx = $workDir.'/moodle_backup.xml'; |
||
| 1125 | if (!is_file($mbx)) { |
||
| 1126 | throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); |
||
| 1127 | } |
||
| 1128 | $mbDoc = $this->loadXml($mbx); |
||
| 1129 | $mb = new DOMXPath($mbDoc); |
||
| 1130 | |||
| 1131 | $forums = []; |
||
| 1132 | $categories = []; |
||
| 1133 | foreach ($mb->query('//activity') as $node) { |
||
| 1134 | /** @var DOMElement $node */ |
||
| 1135 | $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); |
||
| 1136 | if ('forum' !== $modName) { |
||
| 1137 | continue; |
||
| 1138 | } |
||
| 1139 | |||
| 1140 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 1141 | $moduleXml = ('' !== $dir) ? $workDir.'/'.$dir.'/forum.xml' : null; |
||
| 1142 | if (!$moduleXml || !is_file($moduleXml)) { |
||
| 1143 | if ($this->debug) { |
||
| 1144 | error_log('MOODLE_IMPORT[restoreForums]: skip (forum.xml not found)'); |
||
| 1145 | } |
||
| 1146 | |||
| 1147 | continue; |
||
| 1148 | } |
||
| 1149 | |||
| 1150 | $f = $this->readForumModule($moduleXml); |
||
| 1151 | |||
| 1152 | $oldCatId = (int) ($f['category_id'] ?? 0); |
||
| 1153 | $oldCatTitle = (string) ($f['category_title'] ?? ''); |
||
| 1154 | if ($oldCatId > 0 && !isset($categories[$oldCatId])) { |
||
| 1155 | $categories[$oldCatId] = [ |
||
| 1156 | 'title' => ('' !== $oldCatTitle ? $oldCatTitle : ('Category '.$oldCatId)), |
||
| 1157 | 'description' => '', |
||
| 1158 | ]; |
||
| 1159 | } |
||
| 1160 | |||
| 1161 | $forums[] = [ |
||
| 1162 | 'name' => (string) ($f['name'] ?? 'Forum'), |
||
| 1163 | 'description' => (string) ($f['description'] ?? ''), |
||
| 1164 | 'type' => (string) ($f['type'] ?? 'general'), |
||
| 1165 | 'old_cat_id' => $oldCatId, |
||
| 1166 | ]; |
||
| 1167 | } |
||
| 1168 | |||
| 1169 | if ($this->debug) { |
||
| 1170 | error_log('MOODLE_IMPORT[restoreForums]: found forums='.\count($forums).' cats='.\count($categories)); |
||
| 1171 | } |
||
| 1172 | |||
| 1173 | if (empty($forums) && empty($categories)) { |
||
| 1174 | return ['categories' => 0, 'forums' => 0]; |
||
| 1175 | } |
||
| 1176 | |||
| 1177 | $catMapByOldId = []; // oldCatId => CForumCategory |
||
| 1178 | $newCats = 0; |
||
| 1179 | |||
| 1180 | foreach ($categories as $oldId => $payload) { |
||
| 1181 | $cat = (new CForumCategory()) |
||
| 1182 | ->setTitle((string) $payload['title']) |
||
| 1183 | ->setCatComment((string) $payload['description']) |
||
| 1184 | ->setParent($course) |
||
| 1185 | ->addCourseLink($course, $session) |
||
| 1186 | ; |
||
| 1187 | $em->persist($cat); |
||
| 1188 | $catMapByOldId[(int) $oldId] = $cat; |
||
| 1189 | $newCats++; |
||
| 1190 | } |
||
| 1191 | if ($newCats > 0) { |
||
| 1192 | $em->flush(); |
||
| 1193 | } |
||
| 1194 | |||
| 1195 | $defaultCat = null; |
||
| 1196 | $ensureDefault = function () use (&$defaultCat, $course, $session, $em): CForumCategory { |
||
| 1197 | if ($defaultCat instanceof CForumCategory) { |
||
| 1198 | return $defaultCat; |
||
| 1199 | } |
||
| 1200 | $defaultCat = (new CForumCategory()) |
||
| 1201 | ->setTitle('General') |
||
| 1202 | ->setCatComment('') |
||
| 1203 | ->setParent($course) |
||
| 1204 | ->addCourseLink($course, $session) |
||
| 1205 | ; |
||
| 1206 | $em->persist($defaultCat); |
||
| 1207 | $em->flush(); |
||
| 1208 | |||
| 1209 | return $defaultCat; |
||
| 1210 | }; |
||
| 1211 | |||
| 1212 | $newForums = 0; |
||
| 1213 | |||
| 1214 | foreach ($forums as $F) { |
||
| 1215 | $title = (string) ($F['name'] ?? 'Forum'); |
||
| 1216 | $comment = (string) ($F['description'] ?? ''); |
||
| 1217 | |||
| 1218 | $dstCategory = null; |
||
| 1219 | $oldCatId = (int) ($F['old_cat_id'] ?? 0); |
||
| 1220 | if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) { |
||
| 1221 | $dstCategory = $catMapByOldId[$oldCatId]; |
||
| 1222 | } elseif (1 === \count($catMapByOldId)) { |
||
| 1223 | $dstCategory = reset($catMapByOldId); |
||
| 1224 | } else { |
||
| 1225 | $dstCategory = $ensureDefault(); |
||
| 1226 | } |
||
| 1227 | |||
| 1228 | $forum = (new CForum()) |
||
| 1229 | ->setTitle($title) |
||
| 1230 | ->setForumComment($comment) |
||
| 1231 | ->setForumCategory($dstCategory) |
||
| 1232 | ->setAllowAttachments(1) |
||
| 1233 | ->setAllowNewThreads(1) |
||
| 1234 | ->setDefaultView('flat') |
||
| 1235 | ->setParent($dstCategory) |
||
| 1236 | ->addCourseLink($course, $session) |
||
| 1237 | ; |
||
| 1238 | |||
| 1239 | $em->persist($forum); |
||
| 1240 | $newForums++; |
||
| 1241 | } |
||
| 1242 | |||
| 1243 | $em->flush(); |
||
| 1244 | |||
| 1245 | if ($this->debug) { |
||
| 1246 | error_log('MOODLE_IMPORT[restoreForums]: persisted cats='.$newCats.' forums='.$newForums); |
||
| 1247 | } |
||
| 1248 | |||
| 1249 | return ['categories' => $newCats, 'forums' => $newForums]; |
||
| 1250 | } |
||
| 1251 | |||
| 1252 | /** |
||
| 1253 | * Fast-path: restore only Documents from a Moodle backup, wiring ResourceFiles directly. |
||
| 1254 | * CHANGE: We already normalize paths and explicitly strip a leading "Documents/" segment, |
||
| 1255 | * so the Moodle top-level "Documents" folder is treated as the document root in Chamilo. |
||
| 1256 | */ |
||
| 1257 | public function restoreDocuments( |
||
| 1258 | string $archivePath, |
||
| 1259 | EntityManagerInterface $em, |
||
| 1260 | int $courseRealId, |
||
| 1261 | int $sessionId = 0, |
||
| 1262 | int $sameFileNameOption = 2, |
||
| 1263 | ?object $courseArg = null |
||
| 1264 | ): array { |
||
| 1265 | // Use filtered snapshot if provided; otherwise build from archive |
||
| 1266 | $legacy = $courseArg ?: $this->buildLegacyCourseFromMoodleArchive($archivePath); |
||
| 1267 | |||
| 1268 | if (!\defined('FILE_SKIP')) { |
||
| 1269 | \define('FILE_SKIP', 1); |
||
| 1270 | } |
||
| 1271 | if (!\defined('FILE_RENAME')) { |
||
| 1272 | \define('FILE_RENAME', 2); |
||
| 1273 | } |
||
| 1274 | if (!\defined('FILE_OVERWRITE')) { |
||
| 1275 | \define('FILE_OVERWRITE', 3); |
||
| 1276 | } |
||
| 1277 | $filePolicy = \in_array($sameFileNameOption, [1, 2, 3], true) ? $sameFileNameOption : FILE_RENAME; |
||
| 1278 | |||
| 1279 | /** @var CDocumentRepository $docRepo */ |
||
| 1280 | $docRepo = Container::getDocumentRepository(); |
||
| 1281 | $courseEntity = api_get_course_entity($courseRealId); |
||
| 1282 | $sessionEntity = api_get_session_entity((int) $sessionId); |
||
| 1283 | $groupEntity = api_get_group_entity(0); |
||
| 1284 | |||
| 1285 | if (!$courseEntity) { |
||
| 1286 | throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')'); |
||
| 1287 | } |
||
| 1288 | |||
| 1289 | $srcRoot = rtrim((string) ($legacy->backup_path ?? ''), '/').'/'; |
||
| 1290 | if (!is_dir($srcRoot)) { |
||
| 1291 | throw new RuntimeException('Moodle working directory not found: '.$srcRoot); |
||
| 1292 | } |
||
| 1293 | |||
| 1294 | $docs = []; |
||
| 1295 | if (!empty($legacy->resources['document']) && \is_array($legacy->resources['document'])) { |
||
| 1296 | $docs = $legacy->resources['document']; |
||
| 1297 | } elseif (!empty($legacy->resources['Document']) && \is_array($legacy->resources['Document'])) { |
||
| 1298 | $docs = $legacy->resources['Document']; |
||
| 1299 | } |
||
| 1300 | if (empty($docs)) { |
||
| 1301 | if ($this->debug) { |
||
| 1302 | error_log('MOODLE_IMPORT[restoreDocuments]: no document bucket found'); |
||
| 1303 | } |
||
| 1304 | |||
| 1305 | return ['documents' => 0, 'folders' => 0]; |
||
| 1306 | } |
||
| 1307 | |||
| 1308 | $courseInfo = api_get_course_info(); |
||
| 1309 | $courseDir = (string) ($courseInfo['directory'] ?? $courseInfo['code'] ?? ''); |
||
| 1310 | |||
| 1311 | $DBG = function (string $msg, array $ctx = []): void { |
||
| 1312 | error_log('[MOODLE_IMPORT:RESTORE_DOCS] '.$msg.(empty($ctx) ? '' : ' '.json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))); |
||
| 1313 | }; |
||
| 1314 | |||
| 1315 | // Path normalizer: strip moodle-specific top-level segments like t/, moodle_pages/, Documents/ |
||
| 1316 | // NOTE: This is what makes "Documents" behave as root in Chamilo. |
||
| 1317 | $normalizeMoodleRel = static function (string $rawPath): string { |
||
| 1318 | $p = ltrim($rawPath, '/'); |
||
| 1319 | |||
| 1320 | // Drop "document/" prefix if present |
||
| 1321 | if (str_starts_with($p, 'document/')) { |
||
| 1322 | $p = substr($p, 9); |
||
| 1323 | } |
||
| 1324 | |||
| 1325 | // Strip known moodle export prefixes (order matters: most specific first) |
||
| 1326 | $strip = ['t/', 'moodle_pages/', 'Documents/']; |
||
| 1327 | foreach ($strip as $pre) { |
||
| 1328 | if (str_starts_with($p, $pre)) { |
||
| 1329 | $p = substr($p, \strlen($pre)); |
||
| 1330 | } |
||
| 1331 | } |
||
| 1332 | |||
| 1333 | $p = ltrim($p, '/'); |
||
| 1334 | |||
| 1335 | return '' === $p ? '/' : '/'.$p; |
||
| 1336 | }; |
||
| 1337 | |||
| 1338 | $isFolderItem = static function (object $item): bool { |
||
| 1339 | $e = (isset($item->obj) && \is_object($item->obj)) ? $item->obj : $item; |
||
| 1340 | $ft = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); |
||
| 1341 | if ('folder' === $ft) { |
||
| 1342 | return true; |
||
| 1343 | } |
||
| 1344 | $p = (string) ($e->path ?? ''); |
||
| 1345 | |||
| 1346 | return '' !== $p && '/' === substr($p, -1); |
||
| 1347 | }; |
||
| 1348 | $effectiveEntity = static function (object $item): object { |
||
| 1349 | return (isset($item->obj) && \is_object($item->obj)) ? $item->obj : $item; |
||
| 1350 | }; |
||
| 1351 | |||
| 1352 | // Ensure folder chain and return destination parent iid |
||
| 1353 | $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $sessionId, $DBG) { |
||
| 1354 | $rel = '/'.ltrim($relPath, '/'); |
||
| 1355 | if ('/' === $rel || '' === $rel) { |
||
| 1356 | return 0; |
||
| 1357 | } |
||
| 1358 | $parts = array_values(array_filter(explode('/', trim($rel, '/')))); |
||
| 1359 | |||
| 1360 | // If first segment is "document", skip it; we are already under the course document root. |
||
| 1361 | $start = (isset($parts[0]) && 'document' === strtolower($parts[0])) ? 1 : 0; |
||
| 1362 | |||
| 1363 | $accum = ''; |
||
| 1364 | $parentId = 0; |
||
| 1365 | for ($i = $start; $i < \count($parts); $i++) { |
||
|
|
|||
| 1366 | $seg = $parts[$i]; |
||
| 1367 | $accum = $accum.'/'.$seg; |
||
| 1368 | $title = $seg; |
||
| 1369 | $parent = $parentId ? $docRepo->find($parentId) : $courseEntity; |
||
| 1370 | |||
| 1371 | $existing = $docRepo->findCourseResourceByTitle( |
||
| 1372 | $title, |
||
| 1373 | $parent->getResourceNode(), |
||
| 1374 | $courseEntity, |
||
| 1375 | api_get_session_entity((int) $sessionId), |
||
| 1376 | api_get_group_entity(0) |
||
| 1377 | ); |
||
| 1378 | |||
| 1379 | if ($existing) { |
||
| 1380 | $parentId = method_exists($existing, 'getIid') ? (int) $existing->getIid() : 0; |
||
| 1381 | |||
| 1382 | continue; |
||
| 1383 | } |
||
| 1384 | |||
| 1385 | $entity = DocumentManager::addDocument( |
||
| 1386 | ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']], |
||
| 1387 | $accum, |
||
| 1388 | 'folder', |
||
| 1389 | 0, |
||
| 1390 | $title, |
||
| 1391 | null, |
||
| 1392 | 0, |
||
| 1393 | null, |
||
| 1394 | 0, |
||
| 1395 | (int) $sessionId, |
||
| 1396 | 0, |
||
| 1397 | false, |
||
| 1398 | '', |
||
| 1399 | $parentId, |
||
| 1400 | '' |
||
| 1401 | ); |
||
| 1402 | $parentId = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; |
||
| 1403 | $DBG('ensureFolder:create', ['accum' => $accum, 'iid' => $parentId]); |
||
| 1404 | } |
||
| 1405 | |||
| 1406 | return $parentId; |
||
| 1407 | }; |
||
| 1408 | |||
| 1409 | $isHtmlFile = static function (string $filePath, string $nameGuess): bool { |
||
| 1410 | $ext1 = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); |
||
| 1411 | $ext2 = strtolower(pathinfo($nameGuess, PATHINFO_EXTENSION)); |
||
| 1412 | if (\in_array($ext1, ['html', 'htm'], true) || \in_array($ext2, ['html', 'htm'], true)) { |
||
| 1413 | return true; |
||
| 1414 | } |
||
| 1415 | $peek = (string) @file_get_contents($filePath, false, null, 0, 2048); |
||
| 1416 | if ('' === $peek) { |
||
| 1417 | return false; |
||
| 1418 | } |
||
| 1419 | $s = strtolower($peek); |
||
| 1420 | if (str_contains($s, '<html') || str_contains($s, '<!doctype html')) { |
||
| 1421 | return true; |
||
| 1422 | } |
||
| 1423 | if (\function_exists('finfo_open')) { |
||
| 1424 | $fi = finfo_open(FILEINFO_MIME_TYPE); |
||
| 1425 | if ($fi) { |
||
| 1426 | $mt = @finfo_buffer($fi, $peek) ?: ''; |
||
| 1427 | finfo_close($fi); |
||
| 1428 | if (str_starts_with($mt, 'text/html')) { |
||
| 1429 | return true; |
||
| 1430 | } |
||
| 1431 | } |
||
| 1432 | } |
||
| 1433 | |||
| 1434 | return false; |
||
| 1435 | }; |
||
| 1436 | |||
| 1437 | // Create folders (preserve tree) with normalized paths; track destination iids |
||
| 1438 | $folders = []; // map: normalized folder rel -> iid |
||
| 1439 | $nFolders = 0; |
||
| 1440 | |||
| 1441 | foreach ($docs as $k => $wrap) { |
||
| 1442 | $e = $effectiveEntity($wrap); |
||
| 1443 | if (!$isFolderItem($wrap)) { |
||
| 1444 | continue; |
||
| 1445 | } |
||
| 1446 | |||
| 1447 | $rawPath = (string) ($e->path ?? ''); |
||
| 1448 | if ('' === $rawPath) { |
||
| 1449 | continue; |
||
| 1450 | } |
||
| 1451 | |||
| 1452 | // Normalize to avoid 't/', 'moodle_pages/', 'Documents/' phantom roots |
||
| 1453 | $rel = $normalizeMoodleRel($rawPath); |
||
| 1454 | if ('/' === $rel) { |
||
| 1455 | continue; |
||
| 1456 | } |
||
| 1457 | |||
| 1458 | $parts = array_values(array_filter(explode('/', trim($rel, '/')))); |
||
| 1459 | $accum = ''; |
||
| 1460 | $parentId = 0; |
||
| 1461 | |||
| 1462 | foreach ($parts as $i => $seg) { |
||
| 1463 | $accum .= '/'.$seg; |
||
| 1464 | if (isset($folders[$accum])) { |
||
| 1465 | $parentId = $folders[$accum]; |
||
| 1466 | |||
| 1467 | continue; |
||
| 1468 | } |
||
| 1469 | |||
| 1470 | $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; |
||
| 1471 | $title = ($i === \count($parts) - 1) ? ((string) ($e->title ?? $seg)) : $seg; |
||
| 1472 | |||
| 1473 | $existing = $docRepo->findCourseResourceByTitle( |
||
| 1474 | $title, |
||
| 1475 | $parentRes->getResourceNode(), |
||
| 1476 | $courseEntity, |
||
| 1477 | $sessionEntity, |
||
| 1478 | $groupEntity |
||
| 1479 | ); |
||
| 1480 | |||
| 1481 | if ($existing) { |
||
| 1482 | $iid = method_exists($existing, 'getIid') ? (int) $existing->getIid() : 0; |
||
| 1483 | $DBG('folder:reuse', ['title' => $title, 'iid' => $iid]); |
||
| 1484 | } else { |
||
| 1485 | $entity = DocumentManager::addDocument( |
||
| 1486 | ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']], |
||
| 1487 | $accum, |
||
| 1488 | 'folder', |
||
| 1489 | 0, |
||
| 1490 | $title, |
||
| 1491 | null, |
||
| 1492 | 0, |
||
| 1493 | null, |
||
| 1494 | 0, |
||
| 1495 | (int) $sessionId, |
||
| 1496 | 0, |
||
| 1497 | false, |
||
| 1498 | '', |
||
| 1499 | $parentId, |
||
| 1500 | '' |
||
| 1501 | ); |
||
| 1502 | $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; |
||
| 1503 | $DBG('folder:create', ['title' => $title, 'iid' => $iid]); |
||
| 1504 | $nFolders++; |
||
| 1505 | } |
||
| 1506 | |||
| 1507 | $folders[$accum] = $iid; |
||
| 1508 | $parentId = $iid; |
||
| 1509 | } |
||
| 1510 | |||
| 1511 | if (isset($legacy->resources['document'][$k])) { |
||
| 1512 | $legacy->resources['document'][$k]->destination_id = $parentId; |
||
| 1513 | } |
||
| 1514 | } |
||
| 1515 | |||
| 1516 | // PRE-SCAN: build URL maps for HTML rewriting if helpers exist |
||
| 1517 | $urlMapByRel = []; |
||
| 1518 | $urlMapByBase = []; |
||
| 1519 | foreach ($docs as $k => $wrap) { |
||
| 1520 | $e = $effectiveEntity($wrap); |
||
| 1521 | if ($isFolderItem($wrap)) { |
||
| 1522 | continue; |
||
| 1523 | } |
||
| 1524 | |||
| 1525 | $title = (string) ($e->title ?? basename((string) $e->path)); |
||
| 1526 | $src = $srcRoot.(string) $e->path; |
||
| 1527 | |||
| 1528 | if (!is_file($src) || !is_readable($src)) { |
||
| 1529 | continue; |
||
| 1530 | } |
||
| 1531 | if (!$isHtmlFile($src, $title)) { |
||
| 1532 | continue; |
||
| 1533 | } |
||
| 1534 | |||
| 1535 | $html = (string) @file_get_contents($src); |
||
| 1536 | if ('' === $html) { |
||
| 1537 | continue; |
||
| 1538 | } |
||
| 1539 | |||
| 1540 | try { |
||
| 1541 | $maps = ChamiloHelper::buildUrlMapForHtmlFromPackage( |
||
| 1542 | $html, |
||
| 1543 | $courseDir, |
||
| 1544 | $srcRoot, |
||
| 1545 | $folders, |
||
| 1546 | $ensureFolder, |
||
| 1547 | $docRepo, |
||
| 1548 | $courseEntity, |
||
| 1549 | $sessionEntity, |
||
| 1550 | $groupEntity, |
||
| 1551 | (int) $sessionId, |
||
| 1552 | (int) $filePolicy, |
||
| 1553 | $DBG |
||
| 1554 | ); |
||
| 1555 | |||
| 1556 | foreach ($maps['byRel'] ?? [] as $kRel => $vUrl) { |
||
| 1557 | if (!isset($urlMapByRel[$kRel])) { |
||
| 1558 | $urlMapByRel[$kRel] = $vUrl; |
||
| 1559 | } |
||
| 1560 | } |
||
| 1561 | foreach ($maps['byBase'] ?? [] as $kBase => $vUrl) { |
||
| 1562 | if (!isset($urlMapByBase[$kBase])) { |
||
| 1563 | $urlMapByBase[$kBase] = $vUrl; |
||
| 1564 | } |
||
| 1565 | } |
||
| 1566 | } catch (Throwable $te) { |
||
| 1567 | $DBG('html:map:failed', ['err' => $te->getMessage()]); |
||
| 1568 | } |
||
| 1569 | } |
||
| 1570 | $DBG('global.map.stats', ['byRel' => \count($urlMapByRel), 'byBase' => \count($urlMapByBase)]); |
||
| 1571 | |||
| 1572 | // Import files (HTML rewritten before addDocument; binaries via realPath) |
||
| 1573 | $nFiles = 0; |
||
| 1574 | foreach ($docs as $k => $wrap) { |
||
| 1575 | $e = $effectiveEntity($wrap); |
||
| 1576 | if ($isFolderItem($wrap)) { |
||
| 1577 | continue; |
||
| 1578 | } |
||
| 1579 | |||
| 1580 | $rawTitle = (string) ($e->title ?? basename((string) $e->path)); |
||
| 1581 | $srcPath = $srcRoot.(string) $e->path; |
||
| 1582 | |||
| 1583 | if (!is_file($srcPath) || !is_readable($srcPath)) { |
||
| 1584 | $DBG('file:skip:src-missing', ['src' => $srcPath, 'title' => $rawTitle]); |
||
| 1585 | |||
| 1586 | continue; |
||
| 1587 | } |
||
| 1588 | |||
| 1589 | // Parent folder: from normalized path (this strips "Documents/") |
||
| 1590 | $rel = $normalizeMoodleRel((string) $e->path); |
||
| 1591 | $parentRel = rtrim(\dirname($rel), '/'); |
||
| 1592 | $parentId = $folders[$parentRel] ?? 0; |
||
| 1593 | if (!$parentId) { |
||
| 1594 | $parentId = $ensureFolder($parentRel); |
||
| 1595 | $folders[$parentRel] = $parentId; |
||
| 1596 | } |
||
| 1597 | $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; |
||
| 1598 | |||
| 1599 | // Handle name collisions based on $filePolicy |
||
| 1600 | $findExistingIid = function (string $title) use ($docRepo, $parentRes, $courseEntity, $sessionEntity, $groupEntity): ?int { |
||
| 1601 | $ex = $docRepo->findCourseResourceByTitle( |
||
| 1602 | $title, |
||
| 1603 | $parentRes->getResourceNode(), |
||
| 1604 | $courseEntity, |
||
| 1605 | $sessionEntity, |
||
| 1606 | $groupEntity |
||
| 1607 | ); |
||
| 1608 | |||
| 1609 | return $ex && method_exists($ex, 'getIid') ? (int) $ex->getIid() : null; |
||
| 1610 | }; |
||
| 1611 | |||
| 1612 | $baseTitle = $rawTitle; |
||
| 1613 | $finalTitle = $baseTitle; |
||
| 1614 | |||
| 1615 | $existsIid = $findExistingIid($finalTitle); |
||
| 1616 | if ($existsIid) { |
||
| 1617 | $DBG('file:collision', ['title' => $finalTitle, 'policy' => $filePolicy]); |
||
| 1618 | if (FILE_SKIP === $filePolicy) { |
||
| 1619 | if (isset($legacy->resources['document'][$k])) { |
||
| 1620 | $legacy->resources['document'][$k]->destination_id = $existsIid; |
||
| 1621 | } |
||
| 1622 | |||
| 1623 | continue; |
||
| 1624 | } |
||
| 1625 | if (FILE_RENAME === $filePolicy) { |
||
| 1626 | $pi = pathinfo($baseTitle); |
||
| 1627 | $name = $pi['filename'] ?? $baseTitle; |
||
| 1628 | $ext2 = isset($pi['extension']) && '' !== $pi['extension'] ? '.'.$pi['extension'] : ''; |
||
| 1629 | $i = 1; |
||
| 1630 | while ($findExistingIid($finalTitle)) { |
||
| 1631 | $finalTitle = $name.'_'.$i.$ext2; |
||
| 1632 | $i++; |
||
| 1633 | } |
||
| 1634 | } |
||
| 1635 | // FILE_OVERWRITE => let DocumentManager handle it |
||
| 1636 | } |
||
| 1637 | |||
| 1638 | // Prepare payload for addDocument |
||
| 1639 | $isHtml = $isHtmlFile($srcPath, $rawTitle); |
||
| 1640 | $content = ''; |
||
| 1641 | $realPath = ''; |
||
| 1642 | |||
| 1643 | if ($isHtml) { |
||
| 1644 | $raw = @file_get_contents($srcPath) ?: ''; |
||
| 1645 | if (\defined('UTF8_CONVERT') && UTF8_CONVERT) { |
||
| 1646 | $raw = utf8_encode($raw); |
||
| 1647 | } |
||
| 1648 | $DBG('html:rewrite:before', ['title' => $finalTitle, 'maps' => [\count($urlMapByRel), \count($urlMapByBase)]]); |
||
| 1649 | |||
| 1650 | try { |
||
| 1651 | $rew = ChamiloHelper::rewriteLegacyCourseUrlsWithMap( |
||
| 1652 | $raw, |
||
| 1653 | $courseDir, |
||
| 1654 | $urlMapByRel, |
||
| 1655 | $urlMapByBase |
||
| 1656 | ); |
||
| 1657 | $content = (string) ($rew['html'] ?? $raw); |
||
| 1658 | $DBG('html:rewrite:after', ['replaced' => (int) ($rew['replaced'] ?? 0), 'misses' => (int) ($rew['misses'] ?? 0)]); |
||
| 1659 | } catch (Throwable $te) { |
||
| 1660 | $content = $raw; // fallback to original HTML |
||
| 1661 | $DBG('html:rewrite:error', ['err' => $te->getMessage()]); |
||
| 1662 | } |
||
| 1663 | } else { |
||
| 1664 | $realPath = $srcPath; // binary: pass physical path to be streamed into ResourceFile |
||
| 1665 | } |
||
| 1666 | |||
| 1667 | try { |
||
| 1668 | $entity = DocumentManager::addDocument( |
||
| 1669 | ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']], |
||
| 1670 | $rel, |
||
| 1671 | 'file', |
||
| 1672 | (int) ($e->size ?? 0), |
||
| 1673 | $finalTitle, |
||
| 1674 | (string) ($e->comment ?? ''), |
||
| 1675 | 0, |
||
| 1676 | null, |
||
| 1677 | 0, |
||
| 1678 | (int) $sessionId, |
||
| 1679 | 0, |
||
| 1680 | false, |
||
| 1681 | $content, |
||
| 1682 | $parentId, |
||
| 1683 | $realPath |
||
| 1684 | ); |
||
| 1685 | $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; |
||
| 1686 | |||
| 1687 | if (isset($legacy->resources['document'][$k])) { |
||
| 1688 | $legacy->resources['document'][$k]->destination_id = $iid; |
||
| 1689 | } |
||
| 1690 | |||
| 1691 | $nFiles++; |
||
| 1692 | $DBG('file:created', ['title' => $finalTitle, 'iid' => $iid, 'html' => $isHtml ? 1 : 0]); |
||
| 1693 | } catch (Throwable $eX) { |
||
| 1694 | $DBG('file:create:failed', ['title' => $finalTitle, 'error' => $eX->getMessage()]); |
||
| 1695 | } |
||
| 1696 | } |
||
| 1697 | |||
| 1698 | $DBG('summary', ['files' => $nFiles, 'folders' => $nFolders]); |
||
| 1699 | |||
| 1700 | return ['documents' => $nFiles, 'folders' => $nFolders]; |
||
| 1701 | } |
||
| 1702 | |||
| 1703 | /** |
||
| 1704 | * Read documents from activities/resource + files.xml and populate $resources['document']. |
||
| 1705 | * NEW behavior: |
||
| 1706 | * - Treat Moodle's top-level "Documents" folder as the ROOT of /document (do NOT create a "Documents" node). |
||
| 1707 | * - Preserve any real subfolders beneath "Documents/". |
||
| 1708 | * - Copies blobs from files/<hash> to the target /document/... path |
||
| 1709 | * - Adds LP items when section map exists. |
||
| 1710 | */ |
||
| 1711 | private function readDocuments( |
||
| 1712 | string $workDir, |
||
| 1713 | DOMXPath $mb, |
||
| 1714 | array $fileIndex, |
||
| 1715 | array &$resources, |
||
| 1716 | array &$lpMap |
||
| 1717 | ): void { |
||
| 1718 | $resources['document'] ??= []; |
||
| 1719 | |||
| 1720 | // Ensure physical /document dir exists in the working dir (snapshot points there). |
||
| 1721 | $this->ensureDir($workDir.'/document'); |
||
| 1722 | |||
| 1723 | // Helper: strip an optional leading "/Documents" segment *once* |
||
| 1724 | $stripDocumentsRoot = static function (string $p): string { |
||
| 1725 | $p = '/'.ltrim($p, '/'); |
||
| 1726 | if (preg_match('~^/Documents(/|$)~i', $p)) { |
||
| 1727 | $p = substr($p, \strlen('/Documents')); |
||
| 1728 | if (false === $p) { |
||
| 1729 | $p = '/'; |
||
| 1730 | } |
||
| 1731 | } |
||
| 1732 | |||
| 1733 | return '' === $p ? '/' : $p; |
||
| 1734 | }; |
||
| 1735 | |||
| 1736 | // Small helper: ensure folder chain (legacy snapshot + filesystem) under /document, |
||
| 1737 | // skipping an initial "Documents" segment if present. |
||
| 1738 | $ensureFolderChain = function (string $base, string $fp) use (&$resources, $workDir, $stripDocumentsRoot): string { |
||
| 1739 | // Normalize base and fp |
||
| 1740 | $base = rtrim($base, '/'); // expected "/document" |
||
| 1741 | $fp = $this->normalizeSlash($fp ?: '/'); // "/sub/dir/" or "/" |
||
| 1742 | $fp = $stripDocumentsRoot($fp); |
||
| 1743 | |||
| 1744 | if ('/' === $fp || '' === $fp) { |
||
| 1745 | // Just the base /document |
||
| 1746 | $this->ensureDir($workDir.$base); |
||
| 1747 | |||
| 1748 | return $base; |
||
| 1749 | } |
||
| 1750 | |||
| 1751 | // Split and ensure each segment (both on disk and in legacy snapshot) |
||
| 1752 | $parts = array_values(array_filter(explode('/', trim($fp, '/')))); |
||
| 1753 | $accRel = $base; |
||
| 1754 | foreach ($parts as $seg) { |
||
| 1755 | $accRel .= '/'.$seg; |
||
| 1756 | // Create on disk |
||
| 1757 | $this->ensureDir($workDir.$accRel); |
||
| 1758 | // Create in legacy snapshot as a folder node (idempotent) |
||
| 1759 | $this->ensureFolderLegacy($resources['document'], $accRel, $seg); |
||
| 1760 | } |
||
| 1761 | |||
| 1762 | return $accRel; // final parent folder rel path (under /document) |
||
| 1763 | }; |
||
| 1764 | |||
| 1765 | // A) Restore "resource" activities (single-file resources) |
||
| 1766 | foreach ($mb->query('//activity[modulename="resource"]') as $node) { |
||
| 1767 | /** @var DOMElement $node */ |
||
| 1768 | $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); |
||
| 1769 | if ('' === $dir) { |
||
| 1770 | continue; |
||
| 1771 | } |
||
| 1772 | |||
| 1773 | $resourceXml = $workDir.'/'.$dir.'/resource.xml'; |
||
| 1774 | $inforefXml = $workDir.'/'.$dir.'/inforef.xml'; |
||
| 1775 | if (!is_file($resourceXml) || !is_file($inforefXml)) { |
||
| 1776 | continue; |
||
| 1777 | } |
||
| 1778 | |||
| 1779 | // 1) Read resource name/intro |
||
| 1780 | [$resName, $resIntro] = $this->readResourceMeta($resourceXml); |
||
| 1781 | |||
| 1782 | // 2) Resolve referenced file ids |
||
| 1783 | $fileIds = $this->parseInforefFileIds($inforefXml); |
||
| 1784 | if (empty($fileIds)) { |
||
| 1785 | continue; |
||
| 1786 | } |
||
| 1787 | |||
| 1788 | foreach ($fileIds as $fid) { |
||
| 1789 | $f = $fileIndex['byId'][$fid] ?? null; |
||
| 1790 | if (!$f) { |
||
| 1791 | continue; |
||
| 1792 | } |
||
| 1793 | |||
| 1794 | // Keep original structure from files.xml under /document (NOT /document/Documents) |
||
| 1795 | $fp = $this->normalizeSlash($f['filepath'] ?? '/'); // e.g. "/sub/dir/" |
||
| 1796 | $fp = $stripDocumentsRoot($fp); |
||
| 1797 | $base = '/document'; // root in Chamilo |
||
| 1798 | $parentRel = $ensureFolderChain($base, $fp); |
||
| 1799 | |||
| 1800 | $fileName = ltrim((string) ($f['filename'] ?? ''), '/'); |
||
| 1801 | if ('' === $fileName) { |
||
| 1802 | $fileName = 'file_'.$fid; |
||
| 1803 | } |
||
| 1804 | $targetRel = rtrim($parentRel, '/').'/'.$fileName; |
||
| 1805 | $targetAbs = $workDir.$targetRel; |
||
| 1806 | |||
| 1807 | // Copy binary into working dir |
||
| 1808 | $this->ensureDir(\dirname($targetAbs)); |
||
| 1809 | $this->safeCopy($f['blob'], $targetAbs); |
||
| 1810 | |||
| 1811 | // Register in legacy snapshot |
||
| 1812 | $docId = $this->nextId($resources['document']); |
||
| 1813 | $resources['document'][$docId] = $this->mkLegacyItem( |
||
| 1814 | 'document', |
||
| 1815 | $docId, |
||
| 1816 | [ |
||
| 1817 | 'file_type' => 'file', |
||
| 1818 | 'path' => $targetRel, |
||
| 1819 | 'title' => ('' !== $resName ? $resName : (string) $fileName), |
||
| 1820 | 'comment' => $resIntro, |
||
| 1821 | 'size' => (string) ($f['filesize'] ?? 0), |
||
| 1822 | ] |
||
| 1823 | ); |
||
| 1824 | |||
| 1825 | // Add to LP of the section, if present (keeps current behavior) |
||
| 1826 | $sectionId = (int) ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); |
||
| 1827 | if ($sectionId > 0 && isset($lpMap[$sectionId])) { |
||
| 1828 | $resourcesDocTitle = $resources['document'][$docId]->title ?? (string) $fileName; |
||
| 1829 | $lpMap[$sectionId]['items'][] = [ |
||
| 1830 | 'item_type' => 'document', |
||
| 1831 | 'ref' => $docId, |
||
| 1832 | 'title' => $resourcesDocTitle, |
||
| 1833 | ]; |
||
| 1834 | } |
||
| 1835 | } |
||
| 1836 | } |
||
| 1837 | |||
| 1838 | // B) Restore files that belong to mod_folder activities. |
||
| 1839 | foreach ($fileIndex['byId'] as $f) { |
||
| 1840 | if (($f['component'] ?? '') !== 'mod_folder') { |
||
| 1841 | continue; |
||
| 1842 | } |
||
| 1843 | |||
| 1844 | // Keep inner structure from files.xml under /document; strip leading "Documents/" |
||
| 1845 | $fp = $this->normalizeSlash($f['filepath'] ?? '/'); // e.g. "/unit1/slide/" |
||
| 1846 | $fp = $stripDocumentsRoot($fp); |
||
| 1847 | $base = '/document'; |
||
| 1848 | |||
| 1849 | // Ensure folder chain exists on disk and in legacy map; get parent rel |
||
| 1850 | $parentRel = $ensureFolderChain($base, $fp); |
||
| 1851 | |||
| 1852 | // Final rel path for the file |
||
| 1853 | $fileName = ltrim((string) ($f['filename'] ?? ''), '/'); |
||
| 1854 | if ('' === $fileName) { |
||
| 1855 | // Defensive: generate name if missing (rare, but keeps import resilient) |
||
| 1856 | $fileName = 'file_'.$this->nextId($resources['document']); |
||
| 1857 | } |
||
| 1858 | $rel = rtrim($parentRel, '/').'/'.$fileName; |
||
| 1859 | |||
| 1860 | // Copy to working dir |
||
| 1861 | $abs = $workDir.$rel; |
||
| 1862 | $this->ensureDir(\dirname($abs)); |
||
| 1863 | $this->safeCopy($f['blob'], $abs); |
||
| 1864 | |||
| 1865 | // Register the file in legacy snapshot (folder nodes were created by ensureFolderChain) |
||
| 1866 | $docId = $this->nextId($resources['document']); |
||
| 1867 | $resources['document'][$docId] = $this->mkLegacyItem( |
||
| 1868 | 'document', |
||
| 1869 | $docId, |
||
| 1870 | [ |
||
| 1871 | 'file_type' => 'file', |
||
| 1872 | 'path' => $rel, |
||
| 1873 | 'title' => (string) ($fileName ?: 'file '.$docId), |
||
| 1874 | 'size' => (string) ($f['filesize'] ?? 0), |
||
| 1875 | 'comment' => '', |
||
| 1876 | ] |
||
| 1877 | ); |
||
| 1878 | } |
||
| 1879 | } |
||
| 1880 | |||
| 1881 | /** |
||
| 1882 | * Extract resource name and intro from activities/resource/resource.xml. |
||
| 1883 | */ |
||
| 1884 | private function readResourceMeta(string $resourceXml): array |
||
| 1892 | } |
||
| 1893 | |||
| 1894 | /** |
||
| 1895 | * Parse file ids referenced by inforef.xml (<inforef><fileref><file><id>..</id>). |
||
| 1896 | */ |
||
| 1897 | private function parseInforefFileIds(string $inforefXml): array |
||
| 1910 | } |
||
| 1911 | |||
| 1912 | /** |
||
| 1913 | * Create (if missing) a legacy folder entry at $folderPath in $bucket and return its id. |
||
| 1914 | */ |
||
| 1915 | private function ensureFolderLegacy(array &$bucket, string $folderPath, string $title): int |
||
| 1916 | { |
||
| 1917 | foreach ($bucket as $k => $it) { |
||
| 1918 | if (($it->file_type ?? '') === 'folder' && (($it->path ?? '') === $folderPath)) { |
||
| 1919 | return (int) $k; |
||
| 1920 | } |
||
| 1921 | } |
||
| 1922 | $id = $this->nextId($bucket); |
||
| 1923 | $bucket[$id] = $this->mkLegacyItem('document', $id, [ |
||
| 1924 | 'file_type' => 'folder', |
||
| 1925 | 'path' => $folderPath, |
||
| 1926 | 'title' => $title, |
||
| 1927 | 'size' => '0', |
||
| 1928 | ]); |
||
| 1929 | |||
| 1930 | return $id; |
||
| 1931 | } |
||
| 1932 | |||
| 1933 | /** |
||
| 1934 | * Copy a file if present (tolerant if blob is missing). |
||
| 1935 | */ |
||
| 1936 | private function safeCopy(string $src, string $dst): void |
||
| 1937 | { |
||
| 1938 | if (!is_file($src)) { |
||
| 1939 | if ($this->debug) { |
||
| 1940 | error_log('MOODLE_IMPORT: blob not found: '.$src); |
||
| 1941 | } |
||
| 1942 | |||
| 1943 | return; |
||
| 1944 | } |
||
| 1945 | if (!is_file($dst)) { |
||
| 1946 | @copy($src, $dst); |
||
| 1947 | } |
||
| 1948 | } |
||
| 1949 | |||
| 1950 | /** |
||
| 1951 | * Normalize a path to have single slashes and end with a slash. |
||
| 1952 | */ |
||
| 1953 | private function normalizeSlash(string $p): string |
||
| 1961 | } |
||
| 1962 | |||
| 1963 | /** |
||
| 1964 | * Igual que en CourseBuilder: crea la “caja” legacy (obj, type, source_id, destination_id, etc.). |
||
| 1965 | */ |
||
| 1966 | private function mkLegacyItem(string $type, int $sourceId, array|object $obj, array $arrayKeysToPromote = []): stdClass |
||
| 2024 | } |
||
| 2025 | } |
||
| 2026 |
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: