Passed
Pull Request — master (#6894)
by
unknown
09:02
created

MoodleImport::mkLegacyItem()   F

Complexity

Conditions 23
Paths 1536

Size

Total Lines 58
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 23
eloc 35
c 1
b 0
f 0
nc 1536
nop 4
dl 0
loc 58
rs 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder;
8
9
use Chamilo\CoreBundle\Entity\Course as CourseEntity;
10
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
11
use Chamilo\CoreBundle\Framework\Container;
12
use Chamilo\CoreBundle\Helpers\ChamiloHelper;
13
use Chamilo\CourseBundle\Component\CourseCopy\Course;
14
use Chamilo\CourseBundle\Entity\CForum;
15
use Chamilo\CourseBundle\Entity\CForumCategory;
16
use Chamilo\CourseBundle\Entity\CLink;
17
use Chamilo\CourseBundle\Entity\CLinkCategory;
18
use Chamilo\CourseBundle\Repository\CDocumentRepository;
19
use Doctrine\ORM\EntityManagerInterface;
20
use DocumentManager;
21
use DOMDocument;
22
use DOMElement;
23
use DOMXPath;
24
use PharData;
25
use RuntimeException;
26
use stdClass;
27
use Throwable;
28
use ZipArchive;
29
30
use const ENT_QUOTES;
31
use const ENT_SUBSTITUTE;
32
use const FILEINFO_MIME_TYPE;
33
use const JSON_UNESCAPED_SLASHES;
34
use const JSON_UNESCAPED_UNICODE;
35
use const PATHINFO_EXTENSION;
36
37
/**
38
 * Moodle importer for Chamilo.
39
 */
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++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

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:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
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) {
0 ignored issues
show
introduced by
$fi is of type resource, thus it always evaluated to false.
Loading history...
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
1885
    {
1886
        $doc = $this->loadXml($resourceXml);
1887
        $xp = new DOMXPath($doc);
1888
        $name = (string) ($xp->query('//resource/name')->item(0)?->nodeValue ?? '');
1889
        $intro = (string) ($xp->query('//resource/intro')->item(0)?->nodeValue ?? '');
1890
1891
        return [$name, $intro];
1892
    }
1893
1894
    /**
1895
     * Parse file ids referenced by inforef.xml (<inforef><fileref><file><id>..</id>).
1896
     */
1897
    private function parseInforefFileIds(string $inforefXml): array
1898
    {
1899
        $doc = $this->loadXml($inforefXml);
1900
        $xp = new DOMXPath($doc);
1901
        $ids = [];
1902
        foreach ($xp->query('//inforef/fileref/file/id') as $n) {
1903
            $v = (int) ($n->nodeValue ?? 0);
1904
            if ($v > 0) {
1905
                $ids[] = $v;
1906
            }
1907
        }
1908
1909
        return array_values(array_unique($ids));
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);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for copy(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

1946
            /** @scrutinizer ignore-unhandled */ @copy($src, $dst);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
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
1954
    {
1955
        if ('' === $p || '.' === $p) {
1956
            return '/';
1957
        }
1958
        $p = preg_replace('#/+#', '/', $p);
1959
1960
        return rtrim($p, '/').'/';
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
1967
    {
1968
        $o = new stdClass();
1969
        $o->type = $type;
1970
        $o->source_id = $sourceId;
1971
        $o->destination_id = null;
1972
        $o->has_obj = true;
1973
        $o->obj = (object) $obj;
1974
1975
        if (!isset($o->obj->iid)) {
1976
            $o->obj->iid = $sourceId;
1977
        }
1978
        if (!isset($o->id)) {
1979
            $o->id = $sourceId;
1980
        }
1981
        if (!isset($o->obj->id)) {
1982
            $o->obj->id = $sourceId;
1983
        }
1984
1985
        // Promote scalars to top-level (like the builder)
1986
        foreach ((array) $obj as $k => $v) {
1987
            if (\is_scalar($v) || null === $v) {
1988
                if (!property_exists($o, $k)) {
1989
                    $o->{$k} = $v;
1990
                }
1991
            }
1992
        }
1993
        // Promote array keys (e.g., items, linked_resources in learnpath)
1994
        foreach ($arrayKeysToPromote as $k) {
1995
            if (isset($obj[$k]) && \is_array($obj[$k])) {
1996
                $o->{$k} = $obj[$k];
1997
            }
1998
        }
1999
2000
        // Special adjustments for documents
2001
        if ('document' === $type) {
2002
            $o->path = (string) ($o->path ?? $o->full_path ?? $o->obj->path ?? $o->obj->full_path ?? '');
2003
            $o->full_path = (string) ($o->full_path ?? $o->path ?? $o->obj->full_path ?? $o->obj->path ?? '');
2004
            $o->file_type = (string) ($o->file_type ?? $o->filetype ?? $o->obj->file_type ?? $o->obj->filetype ?? '');
2005
            $o->filetype = (string) ($o->filetype ?? $o->file_type ?? $o->obj->filetype ?? $o->obj->file_type ?? '');
2006
            $o->title = (string) ($o->title ?? $o->obj->title ?? '');
2007
            if (!isset($o->name) || '' === $o->name || null === $o->name) {
2008
                $o->name = '' !== $o->title ? $o->title : ('document '.$sourceId);
2009
            }
2010
        }
2011
2012
        // Default name if missing
2013
        if (!isset($o->name) || '' === $o->name || null === $o->name) {
2014
            if (isset($obj['name']) && '' !== $obj['name']) {
2015
                $o->name = (string) $obj['name'];
2016
            } elseif (isset($obj['title']) && '' !== $obj['title']) {
2017
                $o->name = (string) $obj['title'];
2018
            } else {
2019
                $o->name = $type.' '.$sourceId;
2020
            }
2021
        }
2022
2023
        return $o;
2024
    }
2025
}
2026