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: