Passed
Pull Request — master (#7027)
by
unknown
12:49 queued 03:07
created

MoodleImport::readForumHeader()   A

Complexity

Conditions 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 5
c 1
b 0
f 1
nop 1
dl 0
loc 7
rs 10
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
    private ?string $ctxArchivePath = null;
43
    private ?EntityManagerInterface $ctxEm = null;
44
    private int $ctxCourseRealId = 0;
45
    private int $ctxSessionId = 0;
46
    private ?int $ctxSameFileNameOption = null;
47
48
    public function __construct(private bool $debug = false) {}
49
50
    public function attachContext(
51
        string $archivePath,
52
        EntityManagerInterface $em,
53
        int $courseRealId,
54
        int $sessionId = 0,
55
        ?int $sameFileNameOption = null
56
    ): self {
57
        $this->ctxArchivePath = $archivePath;
58
        $this->ctxEm = $em;
59
        $this->ctxCourseRealId = $courseRealId;
60
        $this->ctxSessionId = $sessionId;
61
        $this->ctxSameFileNameOption = $sameFileNameOption;
62
63
        return $this;
64
    }
65
66
    /**
67
     * Builds a Course ready for CourseRestorer::restore().
68
     */
69
    public function buildLegacyCourseFromMoodleArchive(string $archivePath): object
70
    {
71
        $rid = \function_exists('random_bytes') ? substr(bin2hex(random_bytes(3)), 0, 6) : substr(sha1((string) mt_rand()), 0, 6);
72
        if ($this->debug) { error_log("MBZ[$rid] START buildLegacyCourseFromMoodleArchive archivePath={$archivePath}"); }
73
74
        // 1) Extract archive to a temp working directory
75
        [$workDir] = $this->extractToTemp($archivePath);
76
        if ($this->debug) { error_log("MBZ[$rid] extracted workDir={$workDir}"); }
77
78
        $mbx = $workDir.'/moodle_backup.xml';
79
        if (!is_file($mbx)) {
80
            if ($this->debug) { error_log("MBZ[$rid] ERROR moodle_backup.xml missing at {$mbx}"); }
81
            throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)');
82
        }
83
84
        // Optional: files.xml for documents/resources
85
        $fx = $workDir.'/files.xml';
86
        $hasFilesXml = is_file($fx);
87
        $fileIndex = $hasFilesXml ? $this->buildFileIndex($fx, $workDir) : ['byId' => [], 'byHash' => []];
88
        if ($this->debug) {
89
            $byId  = isset($fileIndex['byId']) ? count((array) $fileIndex['byId']) : 0;
90
            $byHash= isset($fileIndex['byHash']) ? count((array) $fileIndex['byHash']) : 0;
91
            error_log("MBZ[$rid] indexes moodle_backup.xml=1 files.xml=".($hasFilesXml?1:0)." fileIndex.byId={$byId} fileIndex.byHash={$byHash}");
92
        }
93
94
        // 2) Load main XMLs
95
        $mbDoc = $this->loadXml($mbx);
96
        $mb = new DOMXPath($mbDoc);
97
98
        // Detect meta sidecars early to drive import policy
99
        $hasQuizMeta = $this->hasQuizMeta($workDir);
100
        $hasLpMeta   = $this->hasLearnpathMeta($workDir);
101
        if ($this->debug) { error_log("MBZ[$rid] meta_flags hasQuizMeta=".($hasQuizMeta?1:0)." hasLpMeta=".($hasLpMeta?1:0)); }
102
103
        $skippedQuizXml = 0; // stats
104
105
        // Optional course.xml (course meta, summary)
106
        $courseXmlPath = $workDir.'/course/course.xml';
107
        $courseMeta = $this->readCourseMeta($courseXmlPath); // NEW: safe, tolerant
108
        if ($this->debug) {
109
            $cm = array_intersect_key((array)$courseMeta, array_flip(['fullname','shortname','idnumber','format']));
110
            error_log("MBZ[$rid] course_meta ".json_encode($cm, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
111
        }
112
113
        // 3) Read sections & pre-build LP map (one LP per section)
114
        $sections = $this->readSections($mb);
115
        if ($this->debug) { error_log("MBZ[$rid] sections.count=".count((array)$sections)); }
116
        $lpMap = $this->sectionsToLearnpaths($sections);
117
118
        // 4) Init resource buckets (legacy snapshot shape)
119
        $resources = [
120
            'document'            => [],
121
            'Forum_Category'      => [],
122
            'forum'               => [],
123
            'Link_Category'       => [],
124
            'link'                => [],
125
            'learnpath'           => [],
126
            'learnpath_category'  => [],
127
            'scorm_documents'     => [],
128
            'scorm'               => [],
129
            'announcement'        => [],
130
            'course_descriptions' => [],
131
            'tool_intro'          => [],
132
            'events'              => [],
133
            'quizzes'             => [],
134
            'quiz_question'       => [],
135
            'surveys'             => [],
136
            'works'               => [],
137
            'glossary'            => [],
138
            'wiki'                => [],
139
            'gradebook'           => [],
140
            'assets'              => [],
141
            'attendance'          => [],
142
        ];
143
144
        // 5) Ensure a default Forum Category (fallback)
145
        $defaultForumCatId = 1;
146
        $resources['Forum_Category'][$defaultForumCatId] = $this->mkLegacyItem('Forum_Category', $defaultForumCatId, [
147
            'id'          => $defaultForumCatId,
148
            'cat_title'   => 'General',
149
            'cat_comment' => '',
150
            'title'       => 'General',
151
            'description' => '',
152
        ]);
153
        if ($this->debug) { error_log("MBZ[$rid] forum.default_category id={$defaultForumCatId}"); }
154
155
        // 6) Ensure document working dirs
156
        $this->ensureDir($workDir.'/document');
157
        $this->ensureDir($workDir.'/document/moodle_pages');
158
159
        // Root folder example (kept for consistency; optional)
160
        if (empty($resources['document'])) {
161
            $docFolderId = $this->nextId($resources['document']);
162
            $resources['document'][$docFolderId] = $this->mkLegacyItem('document', $docFolderId, [
163
                'file_type' => 'folder',
164
                'path'      => '/document/moodle_pages',
165
                'title'     => 'moodle_pages',
166
            ]);
167
            if ($this->debug) { error_log("MBZ[$rid] document.root_folder id={$docFolderId} path=/document/moodle_pages"); }
168
        }
169
170
        // 7) Iterate activities and fill buckets
171
        $activityNodes = $mb->query('//activity');
172
        $activityCount = $activityNodes?->length ?? 0;
173
        if ($this->debug) { error_log("MBZ[$rid] activities.count total={$activityCount}"); }
174
        $i = 0; // contador interno (punto en property names no permitido, usar otra var)
175
        $i = 0;
176
177
        foreach ($activityNodes as $node) {
178
            /** @var DOMElement $node */
179
            $i++;
180
            $modName   = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? '');
181
            $dir       = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? '');
182
            $sectionId = (int)    ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0);
183
184
            if ($this->debug) { error_log("MBZ[$rid] activity #{$i} mod={$modName} dir={$dir} section={$sectionId}"); }
185
186
            // Locate module xml path
187
            $moduleXml = ('' !== $modName && '' !== $dir) ? $workDir.'/'.$dir.'/'.$modName.'.xml' : null;
188
            if (!$moduleXml || !is_file($moduleXml)) {
189
                // Some modules use different file names (resource, folder...) – handled separately
190
                if ($this->debug) { error_log("MBZ[$rid] activity #{$i} skip={$modName} reason=module_xml_not_found"); }
191
            }
192
193
            // --- Early mapping: Moodle forum(type=news) -> Chamilo announcements (skip normal forum path)
194
            if ($moduleXml && is_file($moduleXml) && strtolower((string)$modName) === 'forum') {
195
                if ($this->hasChamiloAnnouncementMeta($workDir)) {
196
                    if ($this->debug) { error_log("MBZ[$rid] forum early-map: announcements meta present -> keep non-news, skip news"); }
197
                } else {
198
                    $forumInfo = $this->readForumHeader($moduleXml);
199
                    if ($this->isNewsForum($forumInfo)) {
200
                        if ($this->debug) { error_log("MBZ[$rid] forum NEWS detected -> mapping to announcements"); }
201
                        $anns = $this->readAnnouncementsFromForum($moduleXml, $workDir);
202
203
                        if (empty($anns)) {
204
                            if ($this->debug) { error_log("MBZ[$rid] forum NEWS no-discussions fallback=module intro"); }
205
                            $f = $this->readForumModule($moduleXml);
206
                            $fallbackTitle = (string)($f['name'] ?? 'announcement');
207
                            $fallbackHtml  = (string)($f['description'] ?? '');
208
                            $fallbackTime  = (int)($f['timemodified'] ?? $f['timecreated'] ?? time());
209
                            if ($fallbackHtml !== '') {
210
                                $anns[] = [
211
                                    'title'       => $fallbackTitle,
212
                                    'html'        => $this->wrapHtmlIfNeeded($fallbackHtml, $fallbackTitle),
213
                                    'date'        => date('Y-m-d H:i:s', $fallbackTime),
214
                                    'attachments' => [],
215
                                ];
216
                            }
217
                        }
218
219
                        foreach ($anns as $a) {
220
                            $iid = $this->nextId($resources['announcement']);
221
                            $payload = [
222
                                'title'               => (string) $a['title'],
223
                                'content'             => (string) $this->wrapHtmlIfNeeded($a['html'], (string)$a['title']),
224
                                'date'                => (string) $a['date'],
225
                                'display_order'       => 0,
226
                                'email_sent'          => 0,
227
                                'attachment_path'     => (string) ($a['first_path'] ?? ''),
228
                                'attachment_filename' => (string) ($a['first_name'] ?? ''),
229
                                'attachment_size'     => (int)    ($a['first_size'] ?? 0),
230
                                'attachment_comment'  => '',
231
                                'attachments'         => (array)  ($a['attachments'] ?? []),
232
                            ];
233
                            $resources['announcement'][$iid] = $this->mkLegacyItem('announcement', $iid, $payload, ['attachments']);
234
                        }
235
                        if ($this->debug) { error_log("MBZ[$rid] forum NEWS mapped announcements.count=".count($anns)." -> skip forum case"); }
236
                        continue; // Skip normal forum case
237
                    }
238
                }
239
            }
240
241
            switch ($modName) {
242
                case 'label': {
243
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
244
                    $data = $this->readHtmlModule($moduleXml, 'label');
245
                    $title = (string) ($data['name'] ?? 'Label');
246
                    $html  = (string) $this->wrapHtmlIfNeeded(
247
                        $this->rewritePluginfileBasic((string) ($data['content'] ?? ''), 'label'),
248
                        $title
249
                    );
250
                    $descId = $this->nextId($resources['course_descriptions']);
251
                    $resources['course_descriptions'][$descId] = $this->mkLegacyItem('course_descriptions', $descId, [
252
                        'title'            => $title,
253
                        'content'          => $html,
254
                        'description_type' => 0,
255
                        'source_id'        => $descId,
256
                    ]);
257
                    if ($this->debug) { error_log("MBZ[$rid] label -> course_descriptions id={$descId} title=".json_encode($title)); }
258
                    break;
259
                }
260
261
                case 'page': {
262
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
263
                    $isHomepage = $this->looksLikeCourseHomepage($dir, $moduleXml);
264
265
                    if ($isHomepage) {
266
                        $raw = $this->readPageContent($moduleXml);
267
                        $html = (string) $this->wrapHtmlIfNeeded(
268
                            $this->rewritePluginfileBasic($raw, 'page'),
269
                            get_lang('Introduction')
270
                        );
271
                        if (!isset($resources['tool_intro']['course_homepage'])) {
272
                            $resources['tool_intro']['course_homepage'] = $this->mkLegacyItem('tool_intro', 0, [
273
                                'id'         => 'course_homepage',
274
                                'intro_text' => $html,
275
                            ]);
276
                            if ($this->debug) { error_log("MBZ[$rid] page HOMEPAGE -> tool_intro[course_homepage] set"); }
277
                        } else {
278
                            if ($this->debug) { error_log("MBZ[$rid] page HOMEPAGE -> tool_intro[course_homepage] exists, skip overwrite"); }
279
                        }
280
                        break;
281
                    }
282
283
                    $data = $this->readHtmlModule($moduleXml, $modName);
284
                    $docId = $this->nextId($resources['document']);
285
                    $slug  = $data['slug'] ?: ('page_'.$docId);
286
                    $rel   = 'document/moodle_pages/'.$slug.'.html';
287
                    $abs   = $workDir.'/'.$rel;
288
289
                    $this->ensureDir(\dirname($abs));
290
                    $html = $this->wrapHtmlIfNeeded($data['content'] ?? '', $data['name'] ?? ucfirst($modName));
291
                    file_put_contents($abs, $html);
292
293
                    $resources['document'][$docId] = $this->mkLegacyItem('document', $docId, [
294
                        'file_type' => 'file',
295
                        'path'      => '/'.$rel,
296
                        'title'     => (string) ($data['name'] ?? ucfirst($modName)),
297
                        'size'      => @filesize($abs) ?: 0,
298
                        'comment'   => '',
299
                    ]);
300
                    if ($this->debug) { error_log("MBZ[$rid] page -> document id={$docId} path=/{$rel} title=".json_encode($resources['document'][$docId]->title)); }
301
302
                    if ($sectionId > 0 && isset($lpMap[$sectionId])) {
303
                        $lpMap[$sectionId]['items'][] = [
304
                            'item_type' => 'document',
305
                            'ref'       => $docId,
306
                            'title'     => $data['name'] ?? ucfirst($modName),
307
                        ];
308
                        if ($this->debug) { error_log("MBZ[$rid] page -> LP section={$sectionId} add document ref={$docId}"); }
309
                    }
310
                    break;
311
                }
312
313
                case 'forum': {
314
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
315
316
                    // If there is Chamilo meta for announcements, prefer it and let non-news forums pass through
317
                    if ($this->hasChamiloAnnouncementMeta($workDir)) {
318
                        if ($this->debug) { error_log('MOODLE_IMPORT: announcements meta present → keep forum import for non-news'); }
319
                    }
320
321
                    // 1) Read forum type header
322
                    $forumInfo = $this->readForumHeader($moduleXml); // ['type' => 'news'|'general'|..., 'name' => ..., ...]
323
                    if ($this->debug) {
324
                        error_log('MOODLE_IMPORT: forum header peek -> ' . json_encode([
325
                                'name' => $forumInfo['name'] ?? null,
326
                                'type' => $forumInfo['type'] ?? null,
327
                            ]));
328
                    }
329
330
                    // 2) If it's a "news" forum and meta wasn't used → import as announcements (with fallback)
331
                    if (!$this->hasChamiloAnnouncementMeta($workDir) && $this->isNewsForum($forumInfo)) {
332
                        $anns = $this->readAnnouncementsFromForum($moduleXml, $workDir);
333
                        if ($this->debug) {
334
                            error_log('MOODLE_IMPORT: news-forum detected, announcements extracted=' . count($anns));
335
                        }
336
337
                        if (empty($anns)) {
338
                            if ($this->debug) { error_log('MOODLE_IMPORT: announcements empty -> intro fallback (in switch)'); }
339
                            $f = $this->readForumModule($moduleXml);
340
                            $fallbackTitle = (string)($f['name'] ?? 'announcement');
341
                            $fallbackHtml  = (string)($f['description'] ?? '');
342
                            $fallbackTime  = (int)($f['timemodified'] ?? $f['timecreated'] ?? time());
343
                            if ($fallbackHtml !== '') {
344
                                $anns[] = [
345
                                    'title'       => $fallbackTitle,
346
                                    'html'        => $this->wrapHtmlIfNeeded($fallbackHtml, $fallbackTitle),
347
                                    'date'        => date('Y-m-d H:i:s', $fallbackTime),
348
                                    'attachments' => [],
349
                                ];
350
                            }
351
                        }
352
353
                        foreach ($anns as $a) {
354
                            $iid = $this->nextId($resources['announcement']);
355
                            $payload = [
356
                                'title'               => (string) $a['title'],
357
                                'content'             => (string) $this->wrapHtmlIfNeeded($a['html'], (string)$a['title']),
358
                                'date'                => (string) $a['date'],
359
                                'display_order'       => 0,
360
                                'email_sent'          => 0,
361
                                'attachment_path'     => (string) ($a['first_path'] ?? ''),
362
                                'attachment_filename' => (string) ($a['first_name'] ?? ''),
363
                                'attachment_size'     => (int)    ($a['first_size'] ?? 0),
364
                                'attachment_comment'  => '',
365
                                'attachments'         => (array)  ($a['attachments'] ?? []),
366
                            ];
367
                            $resources['announcement'][$iid] = $this->mkLegacyItem('announcement', $iid, $payload, ['attachments']);
368
                        }
369
370
                        // Do NOT also import as forum
371
                        break;
372
                    }
373
374
                    // 3) Normal forum path (general, Q&A, etc.)
375
                    $f = $this->readForumModule($moduleXml);
376
377
                    $catId    = (int) ($f['category_id'] ?? 0);
378
                    $catTitle = (string) ($f['category_title'] ?? '');
379
                    if ($catId > 0 && !isset($resources['Forum_Category'][$catId])) {
380
                        $resources['Forum_Category'][$catId] = $this->mkLegacyItem('Forum_Category', $catId, [
381
                            'id'          => $catId,
382
                            'cat_title'   => ('' !== $catTitle ? $catTitle : ('Category '.$catId)),
383
                            'cat_comment' => '',
384
                            'title'       => ('' !== $catTitle ? $catTitle : ('Category '.$catId)),
385
                            'description' => '',
386
                        ]);
387
                        if ($this->debug) { error_log("MBZ[$rid] forum -> created Forum_Category id={$catId} title=".json_encode($catTitle)); }
388
                    }
389
                    $dstCatId = $catId > 0 ? $catId : $defaultForumCatId;
390
391
                    $fid = $this->nextId($resources['forum']);
392
                    $resources['forum'][$fid] = $this->mkLegacyItem('forum', $fid, [
393
                        'id'             => $fid,
394
                        'forum_title'    => (string) ($f['name'] ?? 'Forum'),
395
                        'forum_comment'  => (string) ($f['description'] ?? ''),
396
                        'forum_category' => $dstCatId,
397
                        'default_view'   => 'flat',
398
                    ]);
399
                    if ($this->debug) { error_log("MBZ[$rid] forum -> forum id={$fid} category={$dstCatId} title=".json_encode($resources['forum'][$fid]->forum_title)); }
400
401
                    if ($sectionId > 0 && isset($lpMap[$sectionId])) {
402
                        $lpMap[$sectionId]['items'][] = [
403
                            'item_type' => 'forum',
404
                            'ref'       => $fid,
405
                            'title'     => $f['name'] ?? 'Forum',
406
                        ];
407
                        if ($this->debug) { error_log("MBZ[$rid] forum -> LP section={$sectionId} add forum ref={$fid}"); }
408
                    }
409
                    break;
410
                }
411
412
                case 'url': {
413
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
414
                    $u = $this->readUrlModule($moduleXml);
415
                    $urlVal = trim((string) ($u['url'] ?? ''));
416
                    if ('' === $urlVal) { if ($this->debug) { error_log("MBZ[$rid] url -> empty url, skip"); } break; }
417
418
                    $catId    = (int) ($u['category_id'] ?? 0);
419
                    $catTitle = (string) ($u['category_title'] ?? '');
420
                    if ($catId > 0 && !isset($resources['Link_Category'][$catId])) {
421
                        $resources['Link_Category'][$catId] = $this->mkLegacyItem('Link_Category', $catId, [
422
                            'id'          => $catId,
423
                            'title'       => ('' !== $catTitle ? $catTitle : ('Category '.$catId)),
424
                            'description' => '',
425
                        ]);
426
                        if ($this->debug) { error_log("MBZ[$rid] url -> created Link_Category id={$catId} title=".json_encode($catTitle)); }
427
                    }
428
429
                    $lid       = $this->nextId($resources['link']);
430
                    $linkTitle = ($u['name'] ?? '') !== '' ? (string) $u['name'] : $urlVal;
431
432
                    $resources['link'][$lid] = $this->mkLegacyItem('link', $lid, [
433
                        'id'          => $lid,
434
                        'title'       => $linkTitle,
435
                        'description' => '',
436
                        'url'         => $urlVal,
437
                        'target'      => '',
438
                        'category_id' => $catId,
439
                        'on_homepage' => false,
440
                    ]);
441
                    if ($this->debug) { error_log("MBZ[$rid] url -> link id={$lid} url=".json_encode($urlVal)); }
442
443
                    if ($sectionId > 0 && isset($lpMap[$sectionId])) {
444
                        $lpMap[$sectionId]['items'][] = [
445
                            'item_type' => 'link',
446
                            'ref'       => $lid,
447
                            'title'     => $linkTitle,
448
                        ];
449
                        if ($this->debug) { error_log("MBZ[$rid] url -> LP section={$sectionId} add link ref={$lid}"); }
450
                    }
451
                    break;
452
                }
453
454
                case 'scorm': {
455
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
456
                    $sc = $this->readScormModule($moduleXml);
457
                    $sid = $this->nextId($resources['scorm_documents']);
458
459
                    $resources['scorm_documents'][$sid] = $this->mkLegacyItem('scorm_documents', $sid, [
460
                        'id'    => $sid,
461
                        'title' => (string) ($sc['name'] ?? 'SCORM package'),
462
                    ]);
463
                    $resources['scorm'][$sid] = $resources['scorm_documents'][$sid];
464
                    if ($this->debug) { error_log("MBZ[$rid] scorm -> scorm_documents id={$sid}"); }
465
466
                    if ($sectionId > 0 && isset($lpMap[$sectionId])) {
467
                        $lpMap[$sectionId]['items'][] = [
468
                            'item_type' => 'scorm',
469
                            'ref'       => $sid,
470
                            'title'     => $sc['name'] ?? 'SCORM package',
471
                        ];
472
                        if ($this->debug) { error_log("MBZ[$rid] scorm -> LP section={$sectionId} add scorm ref={$sid}"); }
473
                    }
474
                    break;
475
                }
476
477
                case 'quiz': {
478
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
479
                    if ($hasQuizMeta) {
480
                        $peekTitle = $this->peekQuizTitle($moduleXml);
481
                        if ($sectionId > 0 && isset($lpMap[$sectionId])) {
482
                            $lpMap[$sectionId]['items'][] = [
483
                                'item_type' => 'quiz',
484
                                'ref'       => null,
485
                                'title'     => $peekTitle ?? 'Quiz',
486
                            ];
487
                            if ($this->debug) { error_log("MBZ[$rid] quiz(meta) -> LP section={$sectionId} add quiz (ref=null) title=".json_encode($peekTitle ?? 'Quiz')); }
488
                        }
489
                        $skippedQuizXml++;
490
                        if ($this->debug) { error_log("MBZ[$rid] quiz(meta) skipping heavy XML (skipped={$skippedQuizXml})"); }
491
                        break;
492
                    }
493
494
                    [$quiz, $questions] = $this->readQuizModule($workDir, $dir, $moduleXml);
495
                    if (!empty($quiz)) {
496
                        $qid = $this->nextId($resources['quizzes']);
497
                        $resources['quizzes'][$qid] = $this->mkLegacyItem('quizzes', $qid, $quiz);
498
                        if ($this->debug) { error_log("MBZ[$rid] quiz -> quizzes id={$qid} title=".json_encode($quiz['name'] ?? 'Quiz')); }
499
                        if ($sectionId > 0 && isset($lpMap[$sectionId])) {
500
                            $lpMap[$sectionId]['items'][] = [
501
                                'item_type' => 'quiz',
502
                                'ref'       => $qid,
503
                                'title'     => $quiz['name'] ?? 'Quiz',
504
                            ];
505
                            if ($this->debug) { error_log("MBZ[$rid] quiz -> LP section={$sectionId} add quiz ref={$qid}"); }
506
                        }
507
                        foreach ($questions as $q) {
508
                            $qqid = $this->nextId($resources['quiz_question']);
509
                            $resources['quiz_question'][$qqid] = $this->mkLegacyItem('quiz_question', $qqid, $q);
510
                        }
511
                        if ($this->debug) { error_log("MBZ[$rid] quiz -> quiz_question added=".count($questions)); }
512
                    }
513
                    break;
514
                }
515
516
                case 'survey':
517
                case 'feedback': {
518
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
519
                    $s = $this->readSurveyModule($moduleXml, $modName);
520
                    if (!empty($s)) {
521
                        $sid = $this->nextId($resources['surveys']);
522
                        $resources['surveys'][$sid] = $this->mkLegacyItem('surveys', $sid, $s);
523
                        if ($this->debug) { error_log("MBZ[$rid] {$modName} -> surveys id={$sid}"); }
524
                        if ($sectionId > 0 && isset($lpMap[$sectionId])) {
525
                            $lpMap[$sectionId]['items'][] = [
526
                                'item_type' => 'survey',
527
                                'ref'       => $sid,
528
                                'title'     => $s['name'] ?? ucfirst($modName),
529
                            ];
530
                            if ($this->debug) { error_log("MBZ[$rid] {$modName} -> LP section={$sectionId} add survey ref={$sid}"); }
531
                        }
532
                    }
533
                    break;
534
                }
535
536
                case 'assign': {
537
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
538
                    $w = $this->readAssignModule($moduleXml);
539
                    if (!empty($w)) {
540
                        $wid = $this->nextId($resources['works']);
541
                        $resources['works'][$wid] = $this->mkLegacyItem('works', $wid, $w);
542
                        if ($this->debug) { error_log("MBZ[$rid] assign -> works id={$wid}"); }
543
                        if ($sectionId > 0 && isset($lpMap[$sectionId])) {
544
                            $lpMap[$sectionId]['items'][] = [
545
                                'item_type' => 'works',
546
                                'ref'       => $wid,
547
                                'title'     => $w['name'] ?? 'Assignment',
548
                            ];
549
                            if ($this->debug) { error_log("MBZ[$rid] assign -> LP section={$sectionId} add works ref={$wid}"); }
550
                        }
551
                    }
552
                    break;
553
                }
554
555
                case 'glossary': {
556
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
557
                    $g = $this->readGlossaryModule($moduleXml);
558
                    $added = 0;
559
                    foreach ((array) ($g['entries'] ?? []) as $term) {
560
                        $title = (string) ($term['concept'] ?? '');
561
                        if ($title === '') { continue; }
562
                        $descHtml = $this->wrapHtmlIfNeeded((string) ($term['definition'] ?? ''), $title);
563
                        $gid = $this->nextId($resources['glossary']);
564
                        $resources['glossary'][$gid] = $this->mkLegacyItem('glossary', $gid, [
565
                            'id'          => $gid,
566
                            'title'       => $title,
567
                            'description' => $descHtml,
568
                            'approved'    => (int) ($term['approved'] ?? 1),
569
                            'aliases'     => (array) ($term['aliases'] ?? []),
570
                            'userid'      => (int) ($term['userid'] ?? 0),
571
                            'timecreated' => (int) ($term['timecreated'] ?? 0),
572
                            'timemodified'=> (int) ($term['timemodified'] ?? 0),
573
                        ]);
574
                        $added++;
575
                    }
576
                    if ($this->debug) { error_log("MBZ[$rid] glossary -> entries added={$added}"); }
577
                    break;
578
                }
579
580
                case 'wiki': {
581
                    if (!$moduleXml || !is_file($moduleXml)) { break; }
582
                    [$meta, $pages] = $this->readWikiModuleFull($moduleXml);
583
                    $added = 0;
584
                    if (!empty($pages)) {
585
                        foreach ($pages as $p) {
586
                            $payload = [
587
                                'pageId'  => (int) $p['id'],
588
                                'reflink' => (string) ($p['reflink'] ?? $this->slugify((string)$p['title'])),
589
                                'title'   => (string) $p['title'],
590
                                'content' => (string) $this->wrapHtmlIfNeeded($this->rewritePluginfileBasic((string)($p['content'] ?? ''), 'wiki'), (string)$p['title']),
591
                                'userId'  => (int) ($p['userid'] ?? 0),
592
                                'groupId' => 0,
593
                                'dtime'   => date('Y-m-d H:i:s', (int) ($p['timemodified'] ?? time())),
594
                                'progress'=> '',
595
                                'version' => (int) ($p['version'] ?? 1),
596
                                'source_id'       => (int) $p['id'],
597
                                'source_moduleid' => (int) ($meta['moduleid'] ?? 0),
598
                                'source_sectionid'=> (int) ($meta['sectionid'] ?? 0),
599
                            ];
600
                            $wkid = $this->nextId($resources['wiki']);
601
                            $resources['wiki'][$wkid] = $this->mkLegacyItem('wiki', $wkid, $payload);
602
                            $added++;
603
                        }
604
                    }
605
                    if ($this->debug) { error_log("MBZ[$rid] wiki -> pages added={$added}"); }
606
                    break;
607
                }
608
609
                default:
610
                    if ($this->debug) { error_log("MBZ[$rid] unhandled module {$modName}"); }
611
                    break;
612
            }
613
614
            if ($this->debug && ($i % 10 === 0)) {
615
                $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
616
                error_log("MBZ[$rid] progress.counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
617
            }
618
        }
619
620
        // 8) Documents from resource + folder + inlined pluginfile
621
        $this->readDocuments($workDir, $mb, $fileIndex, $resources, $lpMap);
622
        if ($this->debug) {
623
            $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
624
            error_log("MBZ[$rid] after.readDocuments counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
625
        }
626
627
        // 8.1) Import quizzes from meta when present (skipping XML ensured above)
628
        if ($hasQuizMeta) {
629
            $this->tryImportQuizMeta($workDir, $resources);
630
            if ($this->debug) {
631
                $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
632
                error_log("MBZ[$rid] after.quizMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
633
            }
634
        }
635
636
        // 8.2) Prefer LP meta; otherwise fallback to sections
637
        $lpFromMeta = false;
638
        if ($hasLpMeta) {
639
            $lpFromMeta = $this->tryImportLearnpathMeta($workDir, $resources);
640
            if ($this->debug) {
641
                error_log("MBZ[$rid] lpFromMeta=".($lpFromMeta?1:0));
642
                $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
643
                error_log("MBZ[$rid] after.lpMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
644
            }
645
        }
646
647
        // 8.3) Thematic meta (authoritative)
648
        $this->tryImportThematicMeta($workDir, $resources);
649
        if ($this->debug) {
650
            $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
651
            error_log("MBZ[$rid] after.thematicMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
652
        }
653
654
        // 8.4) Attendance meta (authoritative)
655
        $this->tryImportAttendanceMeta($workDir, $resources);
656
        if ($this->debug) {
657
            $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
658
            error_log("MBZ[$rid] after.attendanceMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
659
        }
660
661
        // 8.5) Gradebook meta (authoritative)
662
        $this->tryImportGradebookMeta($workDir, $resources);
663
        if ($this->debug) {
664
            $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
665
            error_log("MBZ[$rid] after.gradebookMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
666
        }
667
668
        // 9) Build learnpaths from sections (fallback only if no meta)
669
        if (!$lpFromMeta && !empty($lpMap)) {
670
            $this->backfillLpRefsFromResources($lpMap, $resources, [
671
                \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz',
672
                'quizzes',
673
                'document',
674
                'forum',
675
                'link',
676
                'scorm',
677
            ]);
678
679
            foreach ($lpMap as $sid => $lp) {
680
                if (empty($resources['learnpath_category'])) {
681
                    $catId = $this->nextId($resources['learnpath_category']);
682
                    $resources['learnpath_category'][$catId] = $this->mkLegacyItem('learnpath_category', $catId, [
683
                        'id'    => $catId,
684
                        'name'  => 'Sections',
685
                        'title' => 'Sections',
686
                    ]);
687
                    if ($this->debug) { error_log("MBZ[$rid] lp.category created id={$catId}"); }
688
                }
689
690
                $linked = $this->collectLinkedFromLpItems($lp['items']);
691
                $lid    = $this->nextId($resources['learnpath']);
692
693
                $resources['learnpath'][$lid] = $this->mkLegacyItem(
694
                    'learnpath',
695
                    $lid,
696
                    [
697
                        'id'   => $lid,
698
                        'name' => (string) $lp['title'],
699
                        'lp_type' => 'section',
700
                        'category_id' => array_key_first($resources['learnpath_category']),
701
                    ],
702
                    ['items','linked_resources']
703
                );
704
                $resources['learnpath'][$lid]->items = array_map(
705
                    static fn (array $i) => [
706
                        'item_type' => (string) $i['item_type'],
707
                        'title'     => (string) $i['title'],
708
                        'path'      => '',
709
                        'ref'       => $i['ref'] ?? null,
710
                    ],
711
                    $lp['items']
712
                );
713
                $resources['learnpath'][$lid]->linked_resources = $linked;
714
715
                if ($this->debug) { error_log("MBZ[$rid] lp.created id={$lid} name=".json_encode($resources['learnpath'][$lid]->name)); }
716
            }
717
        }
718
719
        // 10) Course descriptions / tool intro from course meta (safe fallbacks)
720
        if (!empty($courseMeta['summary'])) {
721
            $cdId = $this->nextId($resources['course_descriptions']);
722
            $resources['course_descriptions'][$cdId] = $this->mkLegacyItem('course_descriptions', $cdId, [
723
                'title'       => 'Course summary',
724
                'description' => (string) $courseMeta['summary'],
725
                'type'        => 'summary',
726
            ]);
727
            $tiId = $this->nextId($resources['tool_intro']);
728
            $resources['tool_intro'][$tiId] = $this->mkLegacyItem('tool_intro', $tiId, [
729
                'tool'    => 'Course home',
730
                'title'   => 'Introduction',
731
                'content' => (string) $courseMeta['summary'],
732
            ]);
733
            if ($this->debug) { error_log("MBZ[$rid] course_meta -> added summary to course_descriptions id={$cdId} and tool_intro id={$tiId}"); }
734
        }
735
736
        // 11) Events (course-level calendar) — optional
737
        $events = $this->readCourseEvents($workDir);
738
        foreach ($events as $e) {
739
            $eid = $this->nextId($resources['events']);
740
            $resources['events'][$eid] = $this->mkLegacyItem('events', $eid, $e);
741
        }
742
        if ($this->debug) { error_log("MBZ[$rid] events.added count=".count($events)); }
743
744
        $resources = $this->canonicalizeResourceBags($resources);
745
        if ($this->debug) {
746
            $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources);
747
            error_log("MBZ[$rid] final.counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
748
        }
749
750
        // 12) Compose Course snapshot
751
        $course = new Course();
752
        $course->resources   = $resources;
753
        $course->backup_path = $workDir;
754
755
        // 13) Meta: "metaexport" + derived moodle meta
756
        $meta = [
757
            'import_source' => 'moodle',
758
            'generated_at'  => date('c'),
759
            'moodle'        => [
760
                'fullname'  => (string) ($courseMeta['fullname'] ?? ''),
761
                'shortname' => (string) ($courseMeta['shortname'] ?? ''),
762
                'idnumber'  => (string) ($courseMeta['idnumber'] ?? ''),
763
                'startdate' => (int)    ($courseMeta['startdate'] ?? 0),
764
                'enddate'   => (int)    ($courseMeta['enddate'] ?? 0),
765
                'format'    => (string) ($courseMeta['format'] ?? ''),
766
            ],
767
        ];
768
769
        // Merge metaexport JSON if present (export_meta.json | meta_export.json)
770
        $meta = $this->mergeMetaExportIfPresent($workDir, $meta); // NEW
771
772
        $course->meta = $meta;
773
        $course->resources['__meta'] = $meta;
774
775
        // 14) Optional course basic info
776
        $ci = \function_exists('api_get_course_info') ? (api_get_course_info() ?: []) : [];
777
        if (property_exists($course, 'code')) {
778
            $course->code = (string) ($ci['code'] ?? '');
779
        }
780
        if (property_exists($course, 'type')) {
781
            $course->type = 'partial';
782
        }
783
        if (property_exists($course, 'encoding')) {
784
            $course->encoding = \function_exists('api_get_system_encoding')
785
                ? api_get_system_encoding()
786
                : 'UTF-8';
787
        }
788
789
        if ($this->debug) {
790
            error_log('MOODLE_IMPORT: resources='.json_encode(
791
                    array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources),
792
                    JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
793
                ));
794
            error_log('MOODLE_IMPORT: backup_path='.$course->backup_path);
795
            if (property_exists($course, 'code') && property_exists($course, 'encoding')) {
796
                error_log('MOODLE_IMPORT: course_code='.$course->code.' encoding='.$course->encoding);
797
            }
798
            error_log("MBZ[$rid] DONE buildLegacyCourseFromMoodleArchive");
799
        }
800
801
        return $course;
802
    }
803
804
805
    private function extractToTemp(string $archivePath): array
806
    {
807
        $base = rtrim(sys_get_temp_dir(), '/').'/moodle_'.date('Ymd_His').'_'.bin2hex(random_bytes(3));
808
        if (!@mkdir($base, 0775, true)) {
809
            throw new RuntimeException('Cannot create temp dir');
810
        }
811
812
        $ext = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION));
813
        if (\in_array($ext, ['zip', 'mbz'], true)) {
814
            $zip = new ZipArchive();
815
            if (true !== $zip->open($archivePath)) {
816
                throw new RuntimeException('Cannot open zip');
817
            }
818
            if (!$zip->extractTo($base)) {
819
                $zip->close();
820
821
                throw new RuntimeException('Cannot extract zip');
822
            }
823
            $zip->close();
824
        } elseif (\in_array($ext, ['gz', 'tgz'], true)) {
825
            $phar = new PharData($archivePath);
826
            $phar->extractTo($base, null, true);
827
        } else {
828
            throw new RuntimeException('Unsupported archive type');
829
        }
830
831
        if (!is_file($base.'/moodle_backup.xml')) {
832
            throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)');
833
        }
834
835
        return [$base];
836
    }
837
838
    private function loadXml(string $path): DOMDocument
839
    {
840
        $xml = @file_get_contents($path);
841
        if (false === $xml || '' === $xml) {
842
            throw new RuntimeException('Cannot read XML: '.$path);
843
        }
844
        $doc = new DOMDocument();
845
        $doc->preserveWhiteSpace = false;
846
        if (!@$doc->loadXML($xml)) {
847
            throw new RuntimeException('Invalid XML: '.$path);
848
        }
849
850
        return $doc;
851
    }
852
853
    /**
854
     * Build an index from files.xml.
855
     * Returns ['byId' => [id => row], 'byHash' => [hash => row]].
856
     * Each row contains: id, hash, filename, filepath, component, filearea, mimetype, filesize, contextid, blob(abs path).
857
     */
858
    private function buildFileIndex(string $filesXmlPath, string $workDir): array
859
    {
860
        $doc = $this->loadXml($filesXmlPath);
861
        $xp = new DOMXPath($doc);
862
863
        $byId = [];
864
        $byHash = [];
865
866
        foreach ($xp->query('//file') as $f) {
867
            /** @var DOMElement $f */
868
            $id = (int) ($f->getAttribute('id') ?? 0);
869
            $hash = (string) ($f->getElementsByTagName('contenthash')->item(0)?->nodeValue ?? '');
870
            if ('' === $hash) {
871
                continue;
872
            }
873
874
            $name = (string) ($f->getElementsByTagName('filename')->item(0)?->nodeValue ?? '');
875
            $fp = (string) ($f->getElementsByTagName('filepath')->item(0)?->nodeValue ?? '/');
876
            $comp = (string) ($f->getElementsByTagName('component')->item(0)?->nodeValue ?? '');
877
            $fa = (string) ($f->getElementsByTagName('filearea')->item(0)?->nodeValue ?? '');
878
            $mime = (string) ($f->getElementsByTagName('mimetype')->item(0)?->nodeValue ?? '');
879
            $size = (int) ($f->getElementsByTagName('filesize')->item(0)?->nodeValue ?? 0);
880
            $ctx = (int) ($f->getElementsByTagName('contextid')->item(0)?->nodeValue ?? 0);
881
882
            $blob = $this->contentHashPath($workDir, $hash);
883
884
            $row = [
885
                'id' => $id,
886
                'hash' => $hash,
887
                'filename' => $name,
888
                'filepath' => $fp,
889
                'component' => $comp,
890
                'filearea' => $fa,
891
                'mimetype' => $mime,
892
                'filesize' => $size,
893
                'contextid' => $ctx,
894
                'blob' => $blob,
895
            ];
896
897
            if ($id > 0) {
898
                $byId[$id] = $row;
899
            }
900
            $byHash[$hash] = $row;
901
        }
902
903
        return ['byId' => $byId, 'byHash' => $byHash];
904
    }
905
906
    private function readSections(DOMXPath $xp): array
907
    {
908
        $out = [];
909
        foreach ($xp->query('//section') as $s) {
910
            /** @var DOMElement $s */
911
            $id = (int) ($s->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0);
912
            if ($id <= 0) {
913
                $id = (int) ($s->getElementsByTagName('number')->item(0)?->nodeValue
914
                    ?? $s->getElementsByTagName('id')->item(0)?->nodeValue
915
                    ?? 0);
916
            }
917
            $name = (string) ($s->getElementsByTagName('name')->item(0)?->nodeValue ?? '');
918
            $summary = (string) ($s->getElementsByTagName('summary')->item(0)?->nodeValue ?? '');
919
            if ($id > 0) {
920
                $out[$id] = ['id' => $id, 'name' => $name, 'summary' => $summary];
921
            }
922
        }
923
924
        return $out;
925
    }
926
927
    private function sectionsToLearnpaths(array $sections): array
928
    {
929
        $map = [];
930
        foreach ($sections as $sid => $s) {
931
            $title = $s['name'] ?: ('Section '.$sid);
932
            $map[(int) $sid] = [
933
                'title' => $title,
934
                'items' => [],
935
            ];
936
        }
937
938
        return $map;
939
    }
940
941
    private function readHtmlModule(string $xmlPath, string $type): array
942
    {
943
        $doc = $this->loadXml($xmlPath);
944
        $xp = new DOMXPath($doc);
945
946
        $name = (string) ($xp->query('//name')->item(0)?->nodeValue ?? ucfirst($type));
947
948
        $content = (string) ($xp->query('//intro')->item(0)?->nodeValue
949
            ?? $xp->query('//content')->item(0)?->nodeValue
950
            ?? '');
951
952
        // NEW: normalize @@PLUGINFILE@@ placeholders so local files resolve
953
        $content = $this->normalizePluginfileContent($content);
954
955
        return [
956
            'name' => $name,
957
            'content' => $content,
958
            'slug' => $this->slugify($name),
959
        ];
960
    }
961
962
    private function readForumModule(string $xmlPath): array
963
    {
964
        $doc = $this->loadXml($xmlPath);
965
        $xp = new DOMXPath($doc);
966
967
        $name = trim((string) ($xp->query('//forum/name')->item(0)?->nodeValue ?? ''));
968
        $description = (string) ($xp->query('//forum/intro')->item(0)?->nodeValue ?? '');
969
        $type = trim((string) ($xp->query('//forum/type')->item(0)?->nodeValue ?? 'general'));
970
971
        $catId = 0;
972
        $catTitle = '';
973
        if (preg_match('/CHAMILO2:forum_category_id:(\d+)/', $description, $m)) {
974
            $catId = (int) $m[1];
975
        }
976
        if (preg_match('/CHAMILO2:forum_category_title:([^\-]+?)\s*-->/u', $description, $m)) {
977
            $catTitle = trim($m[1]);
978
        }
979
980
        return [
981
            'name' => ('' !== $name ? $name : 'Forum'),
982
            'description' => $description,
983
            'type' => ('' !== $type ? $type : 'general'),
984
            'category_id' => $catId,
985
            'category_title' => $catTitle,
986
        ];
987
    }
988
989
    private function readUrlModule(string $xmlPath): array
990
    {
991
        $doc = $this->loadXml($xmlPath);
992
        $xp = new DOMXPath($doc);
993
        $name = trim($xp->query('//url/name')->item(0)?->nodeValue ?? '');
994
        $url = trim($xp->query('//url/externalurl')->item(0)?->nodeValue ?? '');
995
        $intro = (string) ($xp->query('//url/intro')->item(0)?->nodeValue ?? '');
996
997
        $catId = 0;
998
        $catTitle = '';
999
        if (preg_match('/CHAMILO2:link_category_id:(\d+)/', $intro, $m)) {
1000
            $catId = (int) $m[1];
1001
        }
1002
        if (preg_match('/CHAMILO2:link_category_title:([^\-]+?)\s*-->/u', $intro, $m)) {
1003
            $catTitle = trim($m[1]);
1004
        }
1005
1006
        return ['name' => $name, 'url' => $url, 'category_id' => $catId, 'category_title' => $catTitle];
1007
    }
1008
1009
    private function readScormModule(string $xmlPath): array
1010
    {
1011
        $doc = $this->loadXml($xmlPath);
1012
        $xp = new DOMXPath($doc);
1013
1014
        return [
1015
            'name' => (string) ($xp->query('//name')->item(0)?->nodeValue ?? 'SCORM'),
1016
        ];
1017
    }
1018
1019
    private function collectLinkedFromLpItems(array $items): array
1020
    {
1021
        $map = [
1022
            'document' => 'document',
1023
            'forum' => 'forum',
1024
            'url' => 'link',
1025
            'link' => 'link',
1026
            'weblink' => 'link',
1027
            'work' => 'works',
1028
            'student_publication' => 'works',
1029
            'quiz' => 'quiz',
1030
            'exercise' => 'quiz',
1031
            'survey' => 'survey',
1032
            'scorm' => 'scorm',
1033
        ];
1034
1035
        $out = [];
1036
        foreach ($items as $i) {
1037
            $t = (string) ($i['item_type'] ?? '');
1038
            $r = $i['ref'] ?? null;
1039
            if ('' === $t || null === $r) {
1040
                continue;
1041
            }
1042
            $bag = $map[$t] ?? $t;
1043
            $out[$bag] ??= [];
1044
            $out[$bag][] = (int) $r;
1045
        }
1046
1047
        return $out;
1048
    }
1049
1050
    private function nextId(array $bucket): int
1051
    {
1052
        $max = 0;
1053
        foreach ($bucket as $k => $_) {
1054
            $i = is_numeric($k) ? (int) $k : 0;
1055
            if ($i > $max) {
1056
                $max = $i;
1057
            }
1058
        }
1059
1060
        return $max + 1;
1061
    }
1062
1063
    private function slugify(string $s): string
1064
    {
1065
        $t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
1066
        $t = strtolower(preg_replace('/[^a-z0-9]+/', '-', $t ?: $s));
1067
1068
        return trim($t, '-') ?: 'item';
1069
    }
1070
1071
    private function wrapHtmlIfNeeded(string $content, string $title = 'Page'): string
1072
    {
1073
        $trim = ltrim($content);
1074
        $looksHtml = str_contains(strtolower(substr($trim, 0, 200)), '<html')
1075
            || str_contains(strtolower(substr($trim, 0, 200)), '<!doctype');
1076
1077
        if ($looksHtml) {
1078
            return $content;
1079
        }
1080
1081
        return "<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>".
1082
            htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').
1083
            "</title></head><body>\n".$content."\n</body></html>";
1084
    }
1085
1086
    private function ensureDir(string $dir): void
1087
    {
1088
        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
1089
            throw new RuntimeException('Cannot create directory: '.$dir);
1090
        }
1091
    }
1092
1093
    /**
1094
     * Resolve physical path for a given contenthash.
1095
     * Our exporter writes blobs in: files/<first two letters of hash>/<hash>.
1096
     */
1097
    private function contentHashPath(string $workDir, string $hash): string
1098
    {
1099
        $h = trim($hash);
1100
        if ('' === $h || \strlen($h) < 2) {
1101
            return $workDir.'/files/'.$h;
1102
        }
1103
1104
        // export convention: files/<two first letters>/<full-hash>
1105
        return $workDir.'/files/'.substr($h, 0, 2).'/'.$h;
1106
    }
1107
1108
    /**
1109
     * Fast-path: persist only Links (and Link Categories) from a Moodle backup
1110
     * directly with Doctrine entities. This bypasses the generic Restorer so we
1111
     * avoid ResourceType#tool and UserAuthSource#url cascade issues.
1112
     *
1113
     * @return array{categories:int,links:int}
1114
     */
1115
    public function restoreLinks(
1116
        string $archivePath,
1117
        EntityManagerInterface $em,
1118
        int $courseRealId,
1119
        int $sessionId = 0,
1120
        ?object $courseArg = null
1121
    ): array {
1122
        // Resolve parent entities
1123
        /** @var CourseEntity|null $course */
1124
        $course = $em->getRepository(CourseEntity::class)->find($courseRealId);
1125
        if (!$course) {
1126
            throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')');
1127
        }
1128
1129
        /** @var SessionEntity|null $session */
1130
        $session = $sessionId > 0
1131
            ? $em->getRepository(SessionEntity::class)->find($sessionId)
1132
            : null;
1133
1134
        // Fast-path: use filtered snapshot if provided (import/resources selection)
1135
        if ($courseArg && isset($courseArg->resources) && \is_array($courseArg->resources)) {
1136
            $linksBucket = (array) ($courseArg->resources['link'] ?? []);
1137
            $catsBucket = (array) ($courseArg->resources['Link_Category'] ?? []);
1138
1139
            if (empty($linksBucket)) {
1140
                if ($this->debug) {
1141
                    error_log('MOODLE_IMPORT[restoreLinks]: snapshot has no selected links');
1142
                }
1143
1144
                return ['categories' => 0, 'links' => 0];
1145
            }
1146
1147
            // Build set of category ids actually referenced by selected links
1148
            $usedCatIds = [];
1149
            foreach ($linksBucket as $L) {
1150
                $oldCatId = (int) ($L->category_id ?? 0);
1151
                if ($oldCatId > 0) {
1152
                    $usedCatIds[$oldCatId] = true;
1153
                }
1154
            }
1155
1156
            // Persist only needed categories
1157
            $catMapByOldId = [];
1158
            $newCats = 0;
1159
1160
            foreach ($catsBucket as $oldId => $C) {
1161
                if (!isset($usedCatIds[$oldId])) {
1162
                    continue;
1163
                }
1164
1165
                $cat = (new CLinkCategory())
1166
                    ->setTitle((string) ($C->title ?? ('Category '.$oldId)))
1167
                    ->setDescription((string) ($C->description ?? ''))
1168
                ;
1169
1170
                // Parent & course/session links BEFORE persist (prePersist needs a parent)
1171
                if (method_exists($cat, 'setParent')) {
1172
                    $cat->setParent($course);
1173
                } elseif (method_exists($cat, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) {
1174
                    $cat->setParentResourceNode($course->getResourceNode());
1175
                }
1176
                if (method_exists($cat, 'addCourseLink')) {
1177
                    $cat->addCourseLink($course, $session);
1178
                }
1179
1180
                $em->persist($cat);
1181
                $catMapByOldId[(int) $oldId] = $cat;
1182
                $newCats++;
1183
            }
1184
            if ($newCats > 0) {
1185
                $em->flush();
1186
            }
1187
1188
            // Persist selected links
1189
            $newLinks = 0;
1190
            foreach ($linksBucket as $L) {
1191
                $url = trim((string) ($L->url ?? ''));
1192
                if ('' === $url) {
1193
                    continue;
1194
                }
1195
1196
                $title = (string) ($L->title ?? '');
1197
                if ('' === $title) {
1198
                    $title = $url;
1199
                }
1200
1201
                $link = (new CLink())
1202
                    ->setUrl($url)
1203
                    ->setTitle($title)
1204
                    ->setDescription((string) ($L->description ?? ''))
1205
                    ->setTarget((string) ($L->target ?? ''))
1206
                ;
1207
1208
                if (method_exists($link, 'setParent')) {
1209
                    $link->setParent($course);
1210
                } elseif (method_exists($link, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) {
1211
                    $link->setParentResourceNode($course->getResourceNode());
1212
                }
1213
                if (method_exists($link, 'addCourseLink')) {
1214
                    $link->addCourseLink($course, $session);
1215
                }
1216
1217
                $oldCatId = (int) ($L->category_id ?? 0);
1218
                if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) {
1219
                    $link->setCategory($catMapByOldId[$oldCatId]);
1220
                }
1221
1222
                $em->persist($link);
1223
                $newLinks++;
1224
            }
1225
1226
            $em->flush();
1227
1228
            if ($this->debug) {
1229
                error_log('MOODLE_IMPORT[restoreLinks]: persisted (snapshot)='.
1230
                    json_encode(['cats' => $newCats, 'links' => $newLinks], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1231
            }
1232
1233
            return ['categories' => $newCats, 'links' => $newLinks];
1234
        }
1235
1236
        // Extract & open main XML
1237
        [$workDir] = $this->extractToTemp($archivePath);
1238
1239
        $mbx = $workDir.'/moodle_backup.xml';
1240
        if (!is_file($mbx)) {
1241
            throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)');
1242
        }
1243
        $mbDoc = $this->loadXml($mbx);
1244
        $mb = new DOMXPath($mbDoc);
1245
1246
        // Collect URL activities -> { name, url, category hints }
1247
        $links = [];
1248
        $categories = []; // oldCatId => ['title' => ...]
1249
        foreach ($mb->query('//activity') as $node) {
1250
            /** @var DOMElement $node */
1251
            $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? '');
1252
            if ('url' !== $modName) {
1253
                continue;
1254
            }
1255
1256
            $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? '');
1257
            $moduleXml = ('' !== $dir) ? $workDir.'/'.$dir.'/url.xml' : null;
1258
            if (!$moduleXml || !is_file($moduleXml)) {
1259
                if ($this->debug) {
1260
                    error_log('MOODLE_IMPORT[restoreLinks]: skip url (url.xml not found)');
1261
                }
1262
1263
                continue;
1264
            }
1265
1266
            $u = $this->readUrlModule($moduleXml);
1267
1268
            $urlVal = trim((string) ($u['url'] ?? ''));
1269
            if ('' === $urlVal) {
1270
                if ($this->debug) {
1271
                    error_log('MOODLE_IMPORT[restoreLinks]: skip url (empty externalurl)');
1272
                }
1273
1274
                continue;
1275
            }
1276
1277
            $oldCatId = (int) ($u['category_id'] ?? 0);
1278
            $oldCatTitle = (string) ($u['category_title'] ?? '');
1279
            if ($oldCatId > 0 && !isset($categories[$oldCatId])) {
1280
                $categories[$oldCatId] = [
1281
                    'title' => ('' !== $oldCatTitle ? $oldCatTitle : ('Category '.$oldCatId)),
1282
                    'description' => '',
1283
                ];
1284
            }
1285
1286
            $links[] = [
1287
                'name' => (string) ($u['name'] ?? ''),
1288
                'url' => $urlVal,
1289
                'description' => '',
1290
                'target' => '',
1291
                'old_cat_id' => $oldCatId,
1292
            ];
1293
        }
1294
1295
        if ($this->debug) {
1296
            error_log('MOODLE_IMPORT[restoreLinks]: to_persist='.
1297
                json_encode(['cats' => \count($categories), 'links' => \count($links)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1298
        }
1299
1300
        if (empty($links) && empty($categories)) {
1301
            return ['categories' => 0, 'links' => 0];
1302
        }
1303
1304
        // Helper: robustly resolve an IID as int after flush
1305
        $resolveIid = static function ($entity): int {
1306
            // try entity->getIid()
1307
            if (method_exists($entity, 'getIid')) {
1308
                $iid = $entity->getIid();
1309
                if (\is_int($iid)) {
1310
                    return $iid;
1311
                }
1312
                if (is_numeric($iid)) {
1313
                    return (int) $iid;
1314
                }
1315
            }
1316
            // fallback: resource node iid
1317
            if (method_exists($entity, 'getResourceNode')) {
1318
                $node = $entity->getResourceNode();
1319
                if ($node && method_exists($node, 'getIid')) {
1320
                    $nid = $node->getIid();
1321
                    if (\is_int($nid)) {
1322
                        return $nid;
1323
                    }
1324
                    if (is_numeric($nid)) {
1325
                        return (int) $nid;
1326
                    }
1327
                }
1328
            }
1329
            // last resort: primary ID
1330
            if (method_exists($entity, 'getId')) {
1331
                $id = $entity->getId();
1332
                if (\is_int($id)) {
1333
                    return $id;
1334
                }
1335
                if (is_numeric($id)) {
1336
                    return (int) $id;
1337
                }
1338
            }
1339
1340
            return 0;
1341
        };
1342
1343
        // Persist categories first -> flush -> refresh -> map iid
1344
        $catMapByOldId = [];   // oldCatId => CLinkCategory entity
1345
        $iidMapByOldId = [];   // oldCatId => int iid
1346
        $newCats = 0;
1347
1348
        foreach ($categories as $oldId => $payload) {
1349
            $cat = (new CLinkCategory())
1350
                ->setTitle((string) $payload['title'])
1351
                ->setDescription((string) $payload['description'])
1352
            ;
1353
1354
            // Parent & course/session links BEFORE persist (prePersist needs a parent)
1355
            if (method_exists($cat, 'setParent')) {
1356
                $cat->setParent($course);
1357
            } elseif (method_exists($cat, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) {
1358
                $cat->setParentResourceNode($course->getResourceNode());
1359
            }
1360
            if (method_exists($cat, 'addCourseLink')) {
1361
                $cat->addCourseLink($course, $session);
1362
            }
1363
1364
            $em->persist($cat);
1365
            $catMapByOldId[(int) $oldId] = $cat;
1366
            $newCats++;
1367
        }
1368
1369
        // Flush categories to get identifiers assigned
1370
        if ($newCats > 0) {
1371
            $em->flush();
1372
            // Refresh & resolve iid
1373
            foreach ($catMapByOldId as $oldId => $cat) {
1374
                $em->refresh($cat);
1375
                $iidMapByOldId[$oldId] = $resolveIid($cat);
1376
                if ($this->debug) {
1377
                    error_log('MOODLE_IMPORT[restoreLinks]: category persisted {old='.$oldId.', iid='.$iidMapByOldId[$oldId].', title='.$cat->getTitle().'}');
1378
                }
1379
            }
1380
        }
1381
1382
        // Persist links (single flush at the end)
1383
        $newLinks = 0;
1384
        foreach ($links as $L) {
1385
            $url = trim((string) $L['url']);
1386
            if ('' === $url) {
1387
                continue;
1388
            }
1389
1390
            $title = (string) ($L['name'] ?? '');
1391
            if ('' === $title) {
1392
                $title = $url;
1393
            }
1394
1395
            $link = (new CLink())
1396
                ->setUrl($url)
1397
                ->setTitle($title)
1398
                ->setDescription((string) ($L['description'] ?? ''))
1399
                ->setTarget((string) ($L['target'] ?? ''))
1400
            ;
1401
1402
            // Parent & course/session links
1403
            if (method_exists($link, 'setParent')) {
1404
                $link->setParent($course);
1405
            } elseif (method_exists($link, 'setParentResourceNode') && method_exists($course, 'getResourceNode')) {
1406
                $link->setParentResourceNode($course->getResourceNode());
1407
            }
1408
            if (method_exists($link, 'addCourseLink')) {
1409
                $link->addCourseLink($course, $session);
1410
            }
1411
1412
            // Attach category if it existed in Moodle
1413
            $oldCatId = (int) ($L['old_cat_id'] ?? 0);
1414
            if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) {
1415
                $link->setCategory($catMapByOldId[$oldCatId]);
1416
            }
1417
1418
            $em->persist($link);
1419
            $newLinks++;
1420
        }
1421
1422
        $em->flush();
1423
1424
        if ($this->debug) {
1425
            error_log('MOODLE_IMPORT[restoreLinks]: persisted='.
1426
                json_encode(['cats' => $newCats, 'links' => $newLinks], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1427
        }
1428
1429
        return ['categories' => $newCats, 'links' => $newLinks];
1430
    }
1431
1432
    /**
1433
     * Fast-path: persist only Forum Categories and Forums from a Moodle backup,
1434
     * wiring proper parents and course/session links with Doctrine entities.
1435
     *
1436
     * @return array{categories:int,forums:int}
1437
     */
1438
    public function restoreForums(
1439
        string $archivePath,
1440
        EntityManagerInterface $em,
1441
        int $courseRealId,
1442
        int $sessionId = 0,
1443
        ?object $courseArg = null
1444
    ): array {
1445
        /** @var CourseEntity|null $course */
1446
        $course = $em->getRepository(CourseEntity::class)->find($courseRealId);
1447
        if (!$course) {
1448
            throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')');
1449
        }
1450
1451
        /** @var SessionEntity|null $session */
1452
        $session = $sessionId > 0
1453
            ? $em->getRepository(SessionEntity::class)->find($sessionId)
1454
            : null;
1455
1456
        // Fast-path: use filtered snapshot if provided (import/resources selection)
1457
        if ($courseArg && isset($courseArg->resources) && \is_array($courseArg->resources)) {
1458
            $forumsBucket = (array) ($courseArg->resources['forum'] ?? []);
1459
            $catsBucket = (array) ($courseArg->resources['Forum_Category'] ?? []);
1460
1461
            if (empty($forumsBucket)) {
1462
                if ($this->debug) {
1463
                    error_log('MOODLE_IMPORT[restoreForums]: snapshot has no selected forums');
1464
                }
1465
1466
                return ['categories' => 0, 'forums' => 0];
1467
            }
1468
1469
            // Categories actually referenced by selected forums
1470
            $usedCatIds = [];
1471
            foreach ($forumsBucket as $F) {
1472
                $oldCatId = (int) ($F->forum_category ?? 0);
1473
                if ($oldCatId > 0) {
1474
                    $usedCatIds[$oldCatId] = true;
1475
                }
1476
            }
1477
1478
            // Persist only needed categories
1479
            $catMapByOldId = [];
1480
            $newCats = 0;
1481
            foreach ($catsBucket as $oldId => $C) {
1482
                if (!isset($usedCatIds[$oldId])) {
1483
                    continue;
1484
                }
1485
1486
                $cat = (new CForumCategory())
1487
                    ->setTitle((string) ($C->cat_title ?? $C->title ?? ('Category '.$oldId)))
1488
                    ->setCatComment((string) ($C->cat_comment ?? $C->description ?? ''))
1489
                    ->setParent($course)
1490
                    ->addCourseLink($course, $session)
1491
                ;
1492
                $em->persist($cat);
1493
                $catMapByOldId[(int) $oldId] = $cat;
1494
                $newCats++;
1495
            }
1496
            if ($newCats > 0) {
1497
                $em->flush();
1498
            }
1499
1500
            // Fallback default category if none referenced
1501
            $defaultCat = null;
1502
            $ensureDefault = function () use (&$defaultCat, $course, $session, $em): CForumCategory {
1503
                if ($defaultCat instanceof CForumCategory) {
1504
                    return $defaultCat;
1505
                }
1506
                $defaultCat = (new CForumCategory())
1507
                    ->setTitle('General')
1508
                    ->setCatComment('')
1509
                    ->setParent($course)
1510
                    ->addCourseLink($course, $session)
1511
                ;
1512
                $em->persist($defaultCat);
1513
                $em->flush();
1514
1515
                return $defaultCat;
1516
            };
1517
1518
            // Persist selected forums
1519
            $newForums = 0;
1520
            foreach ($forumsBucket as $F) {
1521
                $title = (string) ($F->forum_title ?? $F->title ?? 'Forum');
1522
                $comment = (string) ($F->forum_comment ?? $F->description ?? '');
1523
1524
                $dstCategory = null;
1525
                $oldCatId = (int) ($F->forum_category ?? 0);
1526
                if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) {
1527
                    $dstCategory = $catMapByOldId[$oldCatId];
1528
                } elseif (1 === \count($catMapByOldId)) {
1529
                    $dstCategory = reset($catMapByOldId);
1530
                } else {
1531
                    $dstCategory = $ensureDefault();
1532
                }
1533
1534
                $forum = (new CForum())
1535
                    ->setTitle($title)
1536
                    ->setForumComment($comment)
1537
                    ->setForumCategory($dstCategory)
1538
                    ->setAllowAttachments(1)
1539
                    ->setAllowNewThreads(1)
1540
                    ->setDefaultView('flat')
1541
                    ->setParent($dstCategory)
1542
                    ->addCourseLink($course, $session)
1543
                ;
1544
1545
                $em->persist($forum);
1546
                $newForums++;
1547
            }
1548
1549
            $em->flush();
1550
1551
            if ($this->debug) {
1552
                error_log('MOODLE_IMPORT[restoreForums]: persisted (snapshot) cats='.$newCats.' forums='.$newForums);
1553
            }
1554
1555
            return ['categories' => $newCats + ($defaultCat ? 1 : 0), 'forums' => $newForums];
1556
        }
1557
1558
        [$workDir] = $this->extractToTemp($archivePath);
1559
1560
        $mbx = $workDir.'/moodle_backup.xml';
1561
        if (!is_file($mbx)) {
1562
            throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)');
1563
        }
1564
        $mbDoc = $this->loadXml($mbx);
1565
        $mb = new DOMXPath($mbDoc);
1566
1567
        $forums = [];
1568
        $categories = [];
1569
        foreach ($mb->query('//activity') as $node) {
1570
            /** @var DOMElement $node */
1571
            $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? '');
1572
            if ('forum' !== $modName) {
1573
                continue;
1574
            }
1575
1576
            $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? '');
1577
            $moduleXml = ('' !== $dir) ? $workDir.'/'.$dir.'/forum.xml' : null;
1578
            if (!$moduleXml || !is_file($moduleXml)) {
1579
                if ($this->debug) {
1580
                    error_log('MOODLE_IMPORT[restoreForums]: skip (forum.xml not found)');
1581
                }
1582
1583
                continue;
1584
            }
1585
1586
            $f = $this->readForumModule($moduleXml);
1587
1588
            $oldCatId = (int) ($f['category_id'] ?? 0);
1589
            $oldCatTitle = (string) ($f['category_title'] ?? '');
1590
            if ($oldCatId > 0 && !isset($categories[$oldCatId])) {
1591
                $categories[$oldCatId] = [
1592
                    'title' => ('' !== $oldCatTitle ? $oldCatTitle : ('Category '.$oldCatId)),
1593
                    'description' => '',
1594
                ];
1595
            }
1596
1597
            $forums[] = [
1598
                'name' => (string) ($f['name'] ?? 'Forum'),
1599
                'description' => (string) ($f['description'] ?? ''),
1600
                'type' => (string) ($f['type'] ?? 'general'),
1601
                'old_cat_id' => $oldCatId,
1602
            ];
1603
        }
1604
1605
        if ($this->debug) {
1606
            error_log('MOODLE_IMPORT[restoreForums]: found forums='.\count($forums).' cats='.\count($categories));
1607
        }
1608
1609
        if (empty($forums) && empty($categories)) {
1610
            return ['categories' => 0, 'forums' => 0];
1611
        }
1612
1613
        $catMapByOldId = []; // oldCatId => CForumCategory
1614
        $newCats = 0;
1615
1616
        foreach ($categories as $oldId => $payload) {
1617
            $cat = (new CForumCategory())
1618
                ->setTitle((string) $payload['title'])
1619
                ->setCatComment((string) $payload['description'])
1620
                ->setParent($course)
1621
                ->addCourseLink($course, $session)
1622
            ;
1623
            $em->persist($cat);
1624
            $catMapByOldId[(int) $oldId] = $cat;
1625
            $newCats++;
1626
        }
1627
        if ($newCats > 0) {
1628
            $em->flush();
1629
        }
1630
1631
        $defaultCat = null;
1632
        $ensureDefault = function () use (&$defaultCat, $course, $session, $em): CForumCategory {
1633
            if ($defaultCat instanceof CForumCategory) {
1634
                return $defaultCat;
1635
            }
1636
            $defaultCat = (new CForumCategory())
1637
                ->setTitle('General')
1638
                ->setCatComment('')
1639
                ->setParent($course)
1640
                ->addCourseLink($course, $session)
1641
            ;
1642
            $em->persist($defaultCat);
1643
            $em->flush();
1644
1645
            return $defaultCat;
1646
        };
1647
1648
        $newForums = 0;
1649
1650
        foreach ($forums as $F) {
1651
            $title = (string) ($F['name'] ?? 'Forum');
1652
            $comment = (string) ($F['description'] ?? '');
1653
1654
            $dstCategory = null;
1655
            $oldCatId = (int) ($F['old_cat_id'] ?? 0);
1656
            if ($oldCatId > 0 && isset($catMapByOldId[$oldCatId])) {
1657
                $dstCategory = $catMapByOldId[$oldCatId];
1658
            } elseif (1 === \count($catMapByOldId)) {
1659
                $dstCategory = reset($catMapByOldId);
1660
            } else {
1661
                $dstCategory = $ensureDefault();
1662
            }
1663
1664
            $forum = (new CForum())
1665
                ->setTitle($title)
1666
                ->setForumComment($comment)
1667
                ->setForumCategory($dstCategory)
1668
                ->setAllowAttachments(1)
1669
                ->setAllowNewThreads(1)
1670
                ->setDefaultView('flat')
1671
                ->setParent($dstCategory)
1672
                ->addCourseLink($course, $session)
1673
            ;
1674
1675
            $em->persist($forum);
1676
            $newForums++;
1677
        }
1678
1679
        $em->flush();
1680
1681
        if ($this->debug) {
1682
            error_log('MOODLE_IMPORT[restoreForums]: persisted cats='.$newCats.' forums='.$newForums);
1683
        }
1684
1685
        return ['categories' => $newCats, 'forums' => $newForums];
1686
    }
1687
1688
    /**
1689
     * Fast-path: restore only Documents from a Moodle backup, wiring ResourceFiles directly.
1690
     * CHANGE: We already normalize paths and explicitly strip a leading "Documents/" segment,
1691
     * so the Moodle top-level "Documents" folder is treated as the document root in Chamilo.
1692
     */
1693
    public function restoreDocuments(
1694
        string $archivePath,
1695
        EntityManagerInterface $em,
1696
        int $courseRealId,
1697
        int $sessionId = 0,
1698
        int $sameFileNameOption = 2,
1699
        ?object $courseArg = null
1700
    ): array {
1701
        // Use filtered snapshot if provided; otherwise build from archive
1702
        $legacy = $courseArg ?: $this->buildLegacyCourseFromMoodleArchive($archivePath);
1703
1704
        if (!\defined('FILE_SKIP')) {
1705
            \define('FILE_SKIP', 1);
1706
        }
1707
        if (!\defined('FILE_RENAME')) {
1708
            \define('FILE_RENAME', 2);
1709
        }
1710
        if (!\defined('FILE_OVERWRITE')) {
1711
            \define('FILE_OVERWRITE', 3);
1712
        }
1713
        $filePolicy = \in_array($sameFileNameOption, [1, 2, 3], true) ? $sameFileNameOption : FILE_RENAME;
1714
1715
        /** @var CDocumentRepository $docRepo */
1716
        $docRepo = Container::getDocumentRepository();
1717
        $courseEntity = api_get_course_entity($courseRealId);
1718
        $sessionEntity = api_get_session_entity((int) $sessionId);
1719
        $groupEntity = api_get_group_entity(0);
1720
1721
        if (!$courseEntity) {
1722
            throw new RuntimeException('Destination course entity not found (real_id='.$courseRealId.')');
1723
        }
1724
1725
        $srcRoot = rtrim((string) ($legacy->backup_path ?? ''), '/').'/';
1726
        if (!is_dir($srcRoot)) {
1727
            throw new RuntimeException('Moodle working directory not found: '.$srcRoot);
1728
        }
1729
1730
        $docs = [];
1731
        if (!empty($legacy->resources['document']) && \is_array($legacy->resources['document'])) {
1732
            $docs = $legacy->resources['document'];
1733
        } elseif (!empty($legacy->resources['Document']) && \is_array($legacy->resources['Document'])) {
1734
            $docs = $legacy->resources['Document'];
1735
        }
1736
        if (empty($docs)) {
1737
            if ($this->debug) {
1738
                error_log('MOODLE_IMPORT[restoreDocuments]: no document bucket found');
1739
            }
1740
1741
            return ['documents' => 0, 'folders' => 0];
1742
        }
1743
1744
        $courseInfo = api_get_course_info();
1745
        $courseDir = (string) ($courseInfo['directory'] ?? $courseInfo['code'] ?? '');
1746
1747
        $DBG = function (string $msg, array $ctx = []): void {
1748
            error_log('[MOODLE_IMPORT:RESTORE_DOCS] '.$msg.(empty($ctx) ? '' : ' '.json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)));
1749
        };
1750
1751
        // Path normalizer: strip moodle-specific top-level segments like t/, moodle_pages/, Documents/
1752
        // NOTE: This is what makes "Documents" behave as root in Chamilo.
1753
        $normalizeMoodleRel = static function (string $rawPath): string {
1754
            $p = ltrim($rawPath, '/');
1755
1756
            // Drop "document/" prefix if present
1757
            if (str_starts_with($p, 'document/')) {
1758
                $p = substr($p, 9);
1759
            }
1760
1761
            // Strip known moodle export prefixes (order matters: most specific first)
1762
            $strip = ['t/', 'moodle_pages/', 'Documents/'];
1763
            foreach ($strip as $pre) {
1764
                if (str_starts_with($p, $pre)) {
1765
                    $p = substr($p, \strlen($pre));
1766
                }
1767
            }
1768
1769
            $p = ltrim($p, '/');
1770
1771
            return '' === $p ? '/' : '/'.$p;
1772
        };
1773
1774
        $isFolderItem = static function (object $item): bool {
1775
            $e = (isset($item->obj) && \is_object($item->obj)) ? $item->obj : $item;
1776
            $ft = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
1777
            if ('folder' === $ft) {
1778
                return true;
1779
            }
1780
            $p = (string) ($e->path ?? '');
1781
1782
            return '' !== $p && '/' === substr($p, -1);
1783
        };
1784
        $effectiveEntity = static function (object $item): object {
1785
            return (isset($item->obj) && \is_object($item->obj)) ? $item->obj : $item;
1786
        };
1787
1788
        // Ensure folder chain and return destination parent iid
1789
        $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $sessionId, $DBG) {
1790
            $rel = '/'.ltrim($relPath, '/');
1791
            if ('/' === $rel || '' === $rel) {
1792
                return 0;
1793
            }
1794
            $parts = array_values(array_filter(explode('/', trim($rel, '/'))));
1795
1796
            // If first segment is "document", skip it; we are already under the course document root.
1797
            $start = (isset($parts[0]) && 'document' === strtolower($parts[0])) ? 1 : 0;
1798
1799
            $accum = '';
1800
            $parentId = 0;
1801
            for ($i = $start; $i < \count($parts); $i++) {
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...
1802
                $seg = $parts[$i];
1803
                $accum = $accum.'/'.$seg;
1804
                $title = $seg;
1805
                $parent = $parentId ? $docRepo->find($parentId) : $courseEntity;
1806
1807
                $existing = $docRepo->findCourseResourceByTitle(
1808
                    $title,
1809
                    $parent->getResourceNode(),
1810
                    $courseEntity,
1811
                    api_get_session_entity((int) $sessionId),
1812
                    api_get_group_entity(0)
1813
                );
1814
1815
                if ($existing) {
1816
                    $parentId = method_exists($existing, 'getIid') ? (int) $existing->getIid() : 0;
1817
1818
                    continue;
1819
                }
1820
1821
                $entity = DocumentManager::addDocument(
1822
                    ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']],
1823
                    $accum,
1824
                    'folder',
1825
                    0,
1826
                    $title,
1827
                    null,
1828
                    0,
1829
                    null,
1830
                    0,
1831
                    (int) $sessionId,
1832
                    0,
1833
                    false,
1834
                    '',
1835
                    $parentId,
1836
                    ''
1837
                );
1838
                $parentId = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0;
1839
                $DBG('ensureFolder:create', ['accum' => $accum, 'iid' => $parentId]);
1840
            }
1841
1842
            return $parentId;
1843
        };
1844
1845
        $isHtmlFile = static function (string $filePath, string $nameGuess): bool {
1846
            $ext1 = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
1847
            $ext2 = strtolower(pathinfo($nameGuess, PATHINFO_EXTENSION));
1848
            if (\in_array($ext1, ['html', 'htm'], true) || \in_array($ext2, ['html', 'htm'], true)) {
1849
                return true;
1850
            }
1851
            $peek = (string) @file_get_contents($filePath, false, null, 0, 2048);
1852
            if ('' === $peek) {
1853
                return false;
1854
            }
1855
            $s = strtolower($peek);
1856
            if (str_contains($s, '<html') || str_contains($s, '<!doctype html')) {
1857
                return true;
1858
            }
1859
            if (\function_exists('finfo_open')) {
1860
                $fi = finfo_open(FILEINFO_MIME_TYPE);
1861
                if ($fi) {
0 ignored issues
show
introduced by
$fi is of type resource, thus it always evaluated to false.
Loading history...
1862
                    $mt = @finfo_buffer($fi, $peek) ?: '';
1863
                    finfo_close($fi);
1864
                    if (str_starts_with($mt, 'text/html')) {
1865
                        return true;
1866
                    }
1867
                }
1868
            }
1869
1870
            return false;
1871
        };
1872
1873
        // Create folders (preserve tree) with normalized paths; track destination iids
1874
        $folders = []; // map: normalized folder rel -> iid
1875
        $nFolders = 0;
1876
1877
        foreach ($docs as $k => $wrap) {
1878
            $e = $effectiveEntity($wrap);
1879
            if (!$isFolderItem($wrap)) {
1880
                continue;
1881
            }
1882
1883
            $rawPath = (string) ($e->path ?? '');
1884
            if ('' === $rawPath) {
1885
                continue;
1886
            }
1887
1888
            // Normalize to avoid 't/', 'moodle_pages/', 'Documents/' phantom roots
1889
            $rel = $normalizeMoodleRel($rawPath);
1890
            if ('/' === $rel) {
1891
                continue;
1892
            }
1893
1894
            $parts = array_values(array_filter(explode('/', trim($rel, '/'))));
1895
            $accum = '';
1896
            $parentId = 0;
1897
1898
            foreach ($parts as $i => $seg) {
1899
                $accum .= '/'.$seg;
1900
                if (isset($folders[$accum])) {
1901
                    $parentId = $folders[$accum];
1902
1903
                    continue;
1904
                }
1905
1906
                $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity;
1907
                $title = ($i === \count($parts) - 1) ? ((string) ($e->title ?? $seg)) : $seg;
1908
1909
                $existing = $docRepo->findCourseResourceByTitle(
1910
                    $title,
1911
                    $parentRes->getResourceNode(),
1912
                    $courseEntity,
1913
                    $sessionEntity,
1914
                    $groupEntity
1915
                );
1916
1917
                if ($existing) {
1918
                    $iid = method_exists($existing, 'getIid') ? (int) $existing->getIid() : 0;
1919
                    $DBG('folder:reuse', ['title' => $title, 'iid' => $iid]);
1920
                } else {
1921
                    $entity = DocumentManager::addDocument(
1922
                        ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']],
1923
                        $accum,
1924
                        'folder',
1925
                        0,
1926
                        $title,
1927
                        null,
1928
                        0,
1929
                        null,
1930
                        0,
1931
                        (int) $sessionId,
1932
                        0,
1933
                        false,
1934
                        '',
1935
                        $parentId,
1936
                        ''
1937
                    );
1938
                    $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0;
1939
                    $DBG('folder:create', ['title' => $title, 'iid' => $iid]);
1940
                    $nFolders++;
1941
                }
1942
1943
                $folders[$accum] = $iid;
1944
                $parentId = $iid;
1945
            }
1946
1947
            if (isset($legacy->resources['document'][$k])) {
1948
                $legacy->resources['document'][$k]->destination_id = $parentId;
1949
            }
1950
        }
1951
1952
        // PRE-SCAN: build URL maps for HTML rewriting if helpers exist
1953
        $urlMapByRel = [];
1954
        $urlMapByBase = [];
1955
        foreach ($docs as $k => $wrap) {
1956
            $e = $effectiveEntity($wrap);
1957
            if ($isFolderItem($wrap)) {
1958
                continue;
1959
            }
1960
1961
            $title = (string) ($e->title ?? basename((string) $e->path));
1962
            $src = $srcRoot.(string) $e->path;
1963
1964
            if (!is_file($src) || !is_readable($src)) {
1965
                continue;
1966
            }
1967
            if (!$isHtmlFile($src, $title)) {
1968
                continue;
1969
            }
1970
1971
            $html = (string) @file_get_contents($src);
1972
            if ('' === $html) {
1973
                continue;
1974
            }
1975
1976
            try {
1977
                $maps = ChamiloHelper::buildUrlMapForHtmlFromPackage(
1978
                    $html,
1979
                    $courseDir,
1980
                    $srcRoot,
1981
                    $folders,
1982
                    $ensureFolder,
1983
                    $docRepo,
1984
                    $courseEntity,
1985
                    $sessionEntity,
1986
                    $groupEntity,
1987
                    (int) $sessionId,
1988
                    (int) $filePolicy,
1989
                    $DBG
1990
                );
1991
1992
                foreach ($maps['byRel'] ?? [] as $kRel => $vUrl) {
1993
                    if (!isset($urlMapByRel[$kRel])) {
1994
                        $urlMapByRel[$kRel] = $vUrl;
1995
                    }
1996
                }
1997
                foreach ($maps['byBase'] ?? [] as $kBase => $vUrl) {
1998
                    if (!isset($urlMapByBase[$kBase])) {
1999
                        $urlMapByBase[$kBase] = $vUrl;
2000
                    }
2001
                }
2002
            } catch (Throwable $te) {
2003
                $DBG('html:map:failed', ['err' => $te->getMessage()]);
2004
            }
2005
        }
2006
        $DBG('global.map.stats', ['byRel' => \count($urlMapByRel), 'byBase' => \count($urlMapByBase)]);
2007
2008
        // Import files (HTML rewritten before addDocument; binaries via realPath)
2009
        $nFiles = 0;
2010
        foreach ($docs as $k => $wrap) {
2011
            $e = $effectiveEntity($wrap);
2012
            if ($isFolderItem($wrap)) {
2013
                continue;
2014
            }
2015
2016
            $rawTitle = (string) ($e->title ?? basename((string) $e->path));
2017
            $srcPath = $srcRoot.(string) $e->path;
2018
2019
            if (!is_file($srcPath) || !is_readable($srcPath)) {
2020
                $DBG('file:skip:src-missing', ['src' => $srcPath, 'title' => $rawTitle]);
2021
2022
                continue;
2023
            }
2024
2025
            // Parent folder: from normalized path (this strips "Documents/")
2026
            $rel = $normalizeMoodleRel((string) $e->path);
2027
            $parentRel = rtrim(\dirname($rel), '/');
2028
            $parentId = $folders[$parentRel] ?? 0;
2029
            if (!$parentId) {
2030
                $parentId = $ensureFolder($parentRel);
2031
                $folders[$parentRel] = $parentId;
2032
            }
2033
            $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity;
2034
2035
            // Handle name collisions based on $filePolicy
2036
            $findExistingIid = function (string $title) use ($docRepo, $parentRes, $courseEntity, $sessionEntity, $groupEntity): ?int {
2037
                $ex = $docRepo->findCourseResourceByTitle(
2038
                    $title,
2039
                    $parentRes->getResourceNode(),
2040
                    $courseEntity,
2041
                    $sessionEntity,
2042
                    $groupEntity
2043
                );
2044
2045
                return $ex && method_exists($ex, 'getIid') ? (int) $ex->getIid() : null;
2046
            };
2047
2048
            $baseTitle = $rawTitle;
2049
            $finalTitle = $baseTitle;
2050
2051
            $existsIid = $findExistingIid($finalTitle);
2052
            if ($existsIid) {
2053
                $DBG('file:collision', ['title' => $finalTitle, 'policy' => $filePolicy]);
2054
                if (FILE_SKIP === $filePolicy) {
2055
                    if (isset($legacy->resources['document'][$k])) {
2056
                        $legacy->resources['document'][$k]->destination_id = $existsIid;
2057
                    }
2058
2059
                    continue;
2060
                }
2061
                if (FILE_RENAME === $filePolicy) {
2062
                    $pi = pathinfo($baseTitle);
2063
                    $name = $pi['filename'] ?? $baseTitle;
2064
                    $ext2 = isset($pi['extension']) && '' !== $pi['extension'] ? '.'.$pi['extension'] : '';
2065
                    $i = 1;
2066
                    while ($findExistingIid($finalTitle)) {
2067
                        $finalTitle = $name.'_'.$i.$ext2;
2068
                        $i++;
2069
                    }
2070
                }
2071
                // FILE_OVERWRITE => let DocumentManager handle it
2072
            }
2073
2074
            // Prepare payload for addDocument
2075
            $isHtml = $isHtmlFile($srcPath, $rawTitle);
2076
            $content = '';
2077
            $realPath = '';
2078
2079
            if ($isHtml) {
2080
                $raw = @file_get_contents($srcPath) ?: '';
2081
                if (\defined('UTF8_CONVERT') && UTF8_CONVERT) {
2082
                    $raw = utf8_encode($raw);
2083
                }
2084
                $DBG('html:rewrite:before', ['title' => $finalTitle, 'maps' => [\count($urlMapByRel), \count($urlMapByBase)]]);
2085
2086
                try {
2087
                    $rew = ChamiloHelper::rewriteLegacyCourseUrlsWithMap(
2088
                        $raw,
2089
                        $courseDir,
2090
                        $urlMapByRel,
2091
                        $urlMapByBase
2092
                    );
2093
                    $content = (string) ($rew['html'] ?? $raw);
2094
                    $DBG('html:rewrite:after', ['replaced' => (int) ($rew['replaced'] ?? 0), 'misses' => (int) ($rew['misses'] ?? 0)]);
2095
                } catch (Throwable $te) {
2096
                    $content = $raw; // fallback to original HTML
2097
                    $DBG('html:rewrite:error', ['err' => $te->getMessage()]);
2098
                }
2099
            } else {
2100
                $realPath = $srcPath; // binary: pass physical path to be streamed into ResourceFile
2101
            }
2102
2103
            try {
2104
                $entity = DocumentManager::addDocument(
2105
                    ['real_id' => (int) $courseInfo['real_id'], 'code' => (string) $courseInfo['code']],
2106
                    $rel,
2107
                    'file',
2108
                    (int) ($e->size ?? 0),
2109
                    $finalTitle,
2110
                    (string) ($e->comment ?? ''),
2111
                    0,
2112
                    null,
2113
                    0,
2114
                    (int) $sessionId,
2115
                    0,
2116
                    false,
2117
                    $content,
2118
                    $parentId,
2119
                    $realPath
2120
                );
2121
                $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0;
2122
2123
                if (isset($legacy->resources['document'][$k])) {
2124
                    $legacy->resources['document'][$k]->destination_id = $iid;
2125
                }
2126
2127
                $nFiles++;
2128
                $DBG('file:created', ['title' => $finalTitle, 'iid' => $iid, 'html' => $isHtml ? 1 : 0]);
2129
            } catch (Throwable $eX) {
2130
                $DBG('file:create:failed', ['title' => $finalTitle, 'error' => $eX->getMessage()]);
2131
            }
2132
        }
2133
2134
        $DBG('summary', ['files' => $nFiles, 'folders' => $nFolders]);
2135
2136
        return ['documents' => $nFiles, 'folders' => $nFolders];
2137
    }
2138
2139
    /**
2140
     * Read documents from activities/resource + files.xml and populate $resources['document'].
2141
     * NEW behavior:
2142
     * - Treat Moodle's top-level "Documents" folder as the ROOT of /document (do NOT create a "Documents" node).
2143
     * - Preserve any real subfolders beneath "Documents/".
2144
     * - Copies blobs from files/<hash> to the target /document/... path
2145
     * - Adds LP items when section map exists.
2146
     */
2147
    private function readDocuments(
2148
        string $workDir,
2149
        DOMXPath $mb,
2150
        array $fileIndex,
2151
        array &$resources,
2152
        array &$lpMap
2153
    ): void {
2154
        $resources['document'] ??= [];
2155
2156
        // Ensure physical /document dir exists in the working dir (snapshot points there).
2157
        $this->ensureDir($workDir.'/document');
2158
2159
        // Helper: strip an optional leading "/Documents" segment *once*
2160
        $stripDocumentsRoot = static function (string $p): string {
2161
            $p = '/'.ltrim($p, '/');
2162
            if (preg_match('~^/Documents(/|$)~i', $p)) {
2163
                $p = substr($p, \strlen('/Documents'));
2164
                if (false === $p) {
2165
                    $p = '/';
2166
                }
2167
            }
2168
2169
            return '' === $p ? '/' : $p;
2170
        };
2171
2172
        // Small helper: ensure folder chain (legacy snapshot + filesystem) under /document,
2173
        // skipping an initial "Documents" segment if present.
2174
        $ensureFolderChain = function (string $base, string $fp) use (&$resources, $workDir, $stripDocumentsRoot): string {
2175
            // Normalize base and fp
2176
            $base = rtrim($base, '/');               // expected "/document"
2177
            $fp = $this->normalizeSlash($fp ?: '/'); // "/sub/dir/" or "/"
2178
            $fp = $stripDocumentsRoot($fp);
2179
2180
            if ('/' === $fp || '' === $fp) {
2181
                // Just the base /document
2182
                $this->ensureDir($workDir.$base);
2183
2184
                return $base;
2185
            }
2186
2187
            // Split and ensure each segment (both on disk and in legacy snapshot)
2188
            $parts = array_values(array_filter(explode('/', trim($fp, '/'))));
2189
            $accRel = $base;
2190
            foreach ($parts as $seg) {
2191
                $accRel .= '/'.$seg;
2192
                // Create on disk
2193
                $this->ensureDir($workDir.$accRel);
2194
                // Create in legacy snapshot as a folder node (idempotent)
2195
                $this->ensureFolderLegacy($resources['document'], $accRel, $seg);
2196
            }
2197
2198
            return $accRel; // final parent folder rel path (under /document)
2199
        };
2200
2201
        // A) Restore "resource" activities (single-file resources)
2202
        foreach ($mb->query('//activity[modulename="resource"]') as $node) {
2203
            /** @var DOMElement $node */
2204
            $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? '');
2205
            if ('' === $dir) {
2206
                continue;
2207
            }
2208
2209
            $resourceXml = $workDir.'/'.$dir.'/resource.xml';
2210
            $inforefXml = $workDir.'/'.$dir.'/inforef.xml';
2211
            if (!is_file($resourceXml) || !is_file($inforefXml)) {
2212
                continue;
2213
            }
2214
2215
            // 1) Read resource name/intro
2216
            [$resName, $resIntro] = $this->readResourceMeta($resourceXml);
2217
2218
            // 2) Resolve referenced file ids
2219
            $fileIds = $this->parseInforefFileIds($inforefXml);
2220
            if (empty($fileIds)) {
2221
                continue;
2222
            }
2223
2224
            foreach ($fileIds as $fid) {
2225
                $f = $fileIndex['byId'][$fid] ?? null;
2226
                if (!$f) {
2227
                    continue;
2228
                }
2229
2230
                // Keep original structure from files.xml under /document (NOT /document/Documents)
2231
                $fp = $this->normalizeSlash($f['filepath'] ?? '/'); // e.g. "/sub/dir/"
2232
                $fp = $stripDocumentsRoot($fp);
2233
                $base = '/document'; // root in Chamilo
2234
                $parentRel = $ensureFolderChain($base, $fp);
2235
2236
                $fileName = ltrim((string) ($f['filename'] ?? ''), '/');
2237
                if ('' === $fileName) {
2238
                    $fileName = 'file_'.$fid;
2239
                }
2240
                $targetRel = rtrim($parentRel, '/').'/'.$fileName;
2241
                $targetAbs = $workDir.$targetRel;
2242
2243
                // Copy binary into working dir
2244
                $this->ensureDir(\dirname($targetAbs));
2245
                $this->safeCopy($f['blob'], $targetAbs);
2246
2247
                // Register in legacy snapshot
2248
                $docId = $this->nextId($resources['document']);
2249
                $resources['document'][$docId] = $this->mkLegacyItem(
2250
                    'document',
2251
                    $docId,
2252
                    [
2253
                        'file_type' => 'file',
2254
                        'path' => $targetRel,
2255
                        'title' => ('' !== $resName ? $resName : (string) $fileName),
2256
                        'comment' => $resIntro,
2257
                        'size' => (string) ($f['filesize'] ?? 0),
2258
                    ]
2259
                );
2260
2261
                // Add to LP of the section, if present (keeps current behavior)
2262
                $sectionId = (int) ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0);
2263
                if ($sectionId > 0 && isset($lpMap[$sectionId])) {
2264
                    $resourcesDocTitle = $resources['document'][$docId]->title ?? (string) $fileName;
2265
                    $lpMap[$sectionId]['items'][] = [
2266
                        'item_type' => 'document',
2267
                        'ref' => $docId,
2268
                        'title' => $resourcesDocTitle,
2269
                    ];
2270
                }
2271
            }
2272
        }
2273
2274
        // B) Restore files that belong to mod_folder activities.
2275
        foreach ($fileIndex['byId'] as $f) {
2276
            if (($f['component'] ?? '') !== 'mod_folder') {
2277
                continue;
2278
            }
2279
2280
            // Keep inner structure from files.xml under /document; strip leading "Documents/"
2281
            $fp = $this->normalizeSlash($f['filepath'] ?? '/'); // e.g. "/unit1/slide/"
2282
            $fp = $stripDocumentsRoot($fp);
2283
            $base = '/document';
2284
2285
            // Ensure folder chain exists on disk and in legacy map; get parent rel
2286
            $parentRel = $ensureFolderChain($base, $fp);
2287
2288
            // Final rel path for the file
2289
            $fileName = ltrim((string) ($f['filename'] ?? ''), '/');
2290
            if ('' === $fileName) {
2291
                // Defensive: generate name if missing (rare, but keeps import resilient)
2292
                $fileName = 'file_'.$this->nextId($resources['document']);
2293
            }
2294
            $rel = rtrim($parentRel, '/').'/'.$fileName;
2295
2296
            // Copy to working dir
2297
            $abs = $workDir.$rel;
2298
            $this->ensureDir(\dirname($abs));
2299
            $this->safeCopy($f['blob'], $abs);
2300
2301
            // Register the file in legacy snapshot (folder nodes were created by ensureFolderChain)
2302
            $docId = $this->nextId($resources['document']);
2303
            $resources['document'][$docId] = $this->mkLegacyItem(
2304
                'document',
2305
                $docId,
2306
                [
2307
                    'file_type' => 'file',
2308
                    'path' => $rel,
2309
                    'title' => (string) ($fileName ?: 'file '.$docId),
2310
                    'size' => (string) ($f['filesize'] ?? 0),
2311
                    'comment' => '',
2312
                ]
2313
            );
2314
        }
2315
    }
2316
2317
    /**
2318
     * Extract resource name and intro from activities/resource/resource.xml.
2319
     */
2320
    private function readResourceMeta(string $resourceXml): array
2321
    {
2322
        $doc = $this->loadXml($resourceXml);
2323
        $xp = new DOMXPath($doc);
2324
        $name = (string) ($xp->query('//resource/name')->item(0)?->nodeValue ?? '');
2325
        $intro = (string) ($xp->query('//resource/intro')->item(0)?->nodeValue ?? '');
2326
2327
        return [$name, $intro];
2328
    }
2329
2330
    /**
2331
     * Parse file ids referenced by inforef.xml (<inforef><fileref><file><id>..</id>).
2332
     */
2333
    private function parseInforefFileIds(string $inforefXml): array
2334
    {
2335
        $doc = $this->loadXml($inforefXml);
2336
        $xp = new DOMXPath($doc);
2337
        $ids = [];
2338
        foreach ($xp->query('//inforef/fileref/file/id') as $n) {
2339
            $v = (int) ($n->nodeValue ?? 0);
2340
            if ($v > 0) {
2341
                $ids[] = $v;
2342
            }
2343
        }
2344
2345
        return array_values(array_unique($ids));
2346
    }
2347
2348
    /**
2349
     * Create (if missing) a legacy folder entry at $folderPath in $bucket and return its id.
2350
     */
2351
    private function ensureFolderLegacy(array &$bucket, string $folderPath, string $title): int
2352
    {
2353
        foreach ($bucket as $k => $it) {
2354
            if (($it->file_type ?? '') === 'folder' && (($it->path ?? '') === $folderPath)) {
2355
                return (int) $k;
2356
            }
2357
        }
2358
        $id = $this->nextId($bucket);
2359
        $bucket[$id] = $this->mkLegacyItem('document', $id, [
2360
            'file_type' => 'folder',
2361
            'path' => $folderPath,
2362
            'title' => $title,
2363
            'size' => '0',
2364
        ]);
2365
2366
        return $id;
2367
    }
2368
2369
    /**
2370
     * Copy a file if present (tolerant if blob is missing).
2371
     */
2372
    private function safeCopy(string $src, string $dst): void
2373
    {
2374
        if (!is_file($src)) {
2375
            if ($this->debug) {
2376
                error_log('MOODLE_IMPORT: blob not found: '.$src);
2377
            }
2378
2379
            return;
2380
        }
2381
        if (!is_file($dst)) {
2382
            @copy($src, $dst);
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

2382
            /** @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...
2383
        }
2384
    }
2385
2386
    /**
2387
     * Normalize a path to have single slashes and end with a slash.
2388
     */
2389
    private function normalizeSlash(string $p): string
2390
    {
2391
        if ('' === $p || '.' === $p) {
2392
            return '/';
2393
        }
2394
        $p = preg_replace('#/+#', '/', $p);
2395
2396
        return rtrim($p, '/').'/';
2397
    }
2398
2399
    private function mkLegacyItem(string $type, int $sourceId, array|object $obj, array $arrayKeysToPromote = []): stdClass
2400
    {
2401
        $o = new stdClass();
2402
        $o->type = $type;
2403
        $o->source_id = $sourceId;
2404
        $o->destination_id = null;
2405
        $o->has_obj = true;
2406
        $o->obj = (object) $obj;
2407
2408
        if (!isset($o->obj->iid)) {
2409
            $o->obj->iid = $sourceId;
2410
        }
2411
        if (!isset($o->id)) {
2412
            $o->id = $sourceId;
2413
        }
2414
        if (!isset($o->obj->id)) {
2415
            $o->obj->id = $sourceId;
2416
        }
2417
2418
        // Promote scalars to top-level (like the builder)
2419
        foreach ((array) $obj as $k => $v) {
2420
            if (\is_scalar($v) || null === $v) {
2421
                if (!property_exists($o, $k)) {
2422
                    $o->{$k} = $v;
2423
                }
2424
            }
2425
        }
2426
        // Promote array keys (e.g., items, linked_resources in learnpath)
2427
        foreach ($arrayKeysToPromote as $k) {
2428
            if (isset($obj[$k]) && \is_array($obj[$k])) {
2429
                $o->{$k} = $obj[$k];
2430
            }
2431
        }
2432
2433
        // Special adjustments for documents
2434
        if ('document' === $type) {
2435
            $o->path = (string) ($o->path ?? $o->full_path ?? $o->obj->path ?? $o->obj->full_path ?? '');
2436
            $o->full_path = (string) ($o->full_path ?? $o->path ?? $o->obj->full_path ?? $o->obj->path ?? '');
2437
            $o->file_type = (string) ($o->file_type ?? $o->filetype ?? $o->obj->file_type ?? $o->obj->filetype ?? '');
2438
            $o->filetype = (string) ($o->filetype ?? $o->file_type ?? $o->obj->filetype ?? $o->obj->file_type ?? '');
2439
            $o->title = (string) ($o->title ?? $o->obj->title ?? '');
2440
            if (!isset($o->name) || '' === $o->name || null === $o->name) {
2441
                $o->name = '' !== $o->title ? $o->title : ('document '.$sourceId);
2442
            }
2443
        }
2444
2445
        // Default name if missing
2446
        if (!isset($o->name) || '' === $o->name || null === $o->name) {
2447
            if (isset($obj['name']) && '' !== $obj['name']) {
2448
                $o->name = (string) $obj['name'];
2449
            } elseif (isset($obj['title']) && '' !== $obj['title']) {
2450
                $o->name = (string) $obj['title'];
2451
            } else {
2452
                $o->name = $type.' '.$sourceId;
2453
            }
2454
        }
2455
2456
        return $o;
2457
    }
2458
2459
    /**
2460
     * Replace Moodle @@PLUGINFILE@@ placeholders with package-local /document/ URLs
2461
     * so that later HTML URL mapping can resolve and rewire them correctly.
2462
     * Examples:
2463
     *   @@PLUGINFILE@@/Documents/foo.png  -> /document/foo.png
2464
     *   @@PLUGINFILE@@/documents/bar.pdf  -> /document/bar.pdf
2465
     */
2466
    private function normalizePluginfileContent(string $html): string
2467
    {
2468
        if ('' === $html) {
2469
            return $html;
2470
        }
2471
2472
        // Case-insensitive replace; keep a single leading slash
2473
        // Handles both Documents/ and documents/
2474
        $html = preg_replace('~@@PLUGINFILE@@/(?:Documents|documents)/~i', '/document/', $html);
2475
2476
        return $html;
2477
    }
2478
2479
    private function readCourseMeta(string $courseXmlPath): array
2480
    {
2481
        if (!is_file($courseXmlPath)) {
2482
            return [];
2483
        }
2484
        $doc = $this->loadXml($courseXmlPath);
2485
        $xp  = new DOMXPath($doc);
2486
2487
        $get = static function (string $q) use ($xp) {
2488
            $n = $xp->query($q)->item(0);
2489
2490
            return $n ? (string) $n->nodeValue : '';
2491
        };
2492
2493
        // Moodle course.xml typical nodes
2494
        $fullname  = $get('//course/fullname');
2495
        $shortname = $get('//course/shortname');
2496
        $idnumber  = $get('//course/idnumber');
2497
        $summary   = $get('//course/summary');
2498
        $format    = $get('//course/format');
2499
2500
        $startdate = (int) ($get('//course/startdate') ?: 0);
2501
        $enddate   = (int) ($get('//course/enddate')   ?: 0);
2502
2503
        return [
2504
            'fullname'  => $fullname,
2505
            'shortname' => $shortname,
2506
            'idnumber'  => $idnumber,
2507
            'summary'   => $summary,
2508
            'format'    => $format,
2509
            'startdate' => $startdate,
2510
            'enddate'   => $enddate,
2511
        ];
2512
    }
2513
2514
    private function mergeMetaExportIfPresent(string $workDir, array $meta): array
2515
    {
2516
        $candidates = ['meta_export.json', 'export_meta.json', 'meta.json'];
2517
        foreach ($candidates as $fn) {
2518
            $p = rtrim($workDir,'/').'/'.$fn;
2519
            if (is_file($p)) {
2520
                $raw = @file_get_contents($p);
2521
                if (false !== $raw && '' !== $raw) {
2522
                    $j = json_decode($raw, true);
2523
                    if (\is_array($j)) {
2524
                        // shallow merge under 'metaexport'
2525
                        $meta['metaexport'] = $j;
2526
                    }
2527
                }
2528
                break;
2529
            }
2530
        }
2531
2532
        return $meta;
2533
    }
2534
2535
    private function readQuizModule(string $workDir, string $dir, string $quizXmlPath): array
2536
    {
2537
        $doc = $this->loadXml($quizXmlPath);
2538
        $xp  = new DOMXPath($doc);
2539
2540
        $name  = (string) ($xp->query('//quiz/name')->item(0)?->nodeValue ?? 'Quiz');
2541
        $intro = (string) ($xp->query('//quiz/intro')->item(0)?->nodeValue ?? '');
2542
        $timeopen  = (int) ($xp->query('//quiz/timeopen')->item(0)?->nodeValue ?? 0);
2543
        $timeclose = (int) ($xp->query('//quiz/timeclose')->item(0)?->nodeValue ?? 0);
2544
        $timelimit = (int) ($xp->query('//quiz/timelimit')->item(0)?->nodeValue ?? 0);
2545
2546
        $quiz = [
2547
            'name'        => $name,
2548
            'description' => $intro,
2549
            'timeopen'    => $timeopen,
2550
            'timeclose'   => $timeclose,
2551
            'timelimit'   => $timelimit,
2552
            'attempts'    => (int) ($xp->query('//quiz/attempts')->item(0)?->nodeValue ?? 0),
2553
            'shuffle'     => (int) ($xp->query('//quiz/shufflequestions')->item(0)?->nodeValue ?? 0),
2554
        ];
2555
2556
        // Question bank usually sits at $dir/questions.xml (varies by Moodle version)
2557
        $qxml = $workDir.'/'.$dir.'/questions.xml';
2558
        $questions = [];
2559
        if (is_file($qxml)) {
2560
            $qDoc = $this->loadXml($qxml);
2561
            $qx   = new DOMXPath($qDoc);
2562
2563
            foreach ($qx->query('//question') as $qn) {
2564
                /** @var DOMElement $qn */
2565
                $qtype = strtolower((string) $qn->getAttribute('type'));
2566
                $qname = (string) ($qn->getElementsByTagName('name')->item(0)?->getElementsByTagName('text')->item(0)?->nodeValue ?? '');
2567
                $qtext = (string) ($qn->getElementsByTagName('questiontext')->item(0)?->getElementsByTagName('text')->item(0)?->nodeValue ?? '');
2568
2569
                $q = [
2570
                    'type'       => $qtype ?: 'description',
2571
                    'name'       => $qname ?: 'Question',
2572
                    'questiontext' => $qtext,
2573
                    'answers'    => [],
2574
                    'defaultgrade' => (float) ($qn->getElementsByTagName('defaultgrade')->item(0)?->nodeValue ?? 1.0),
2575
                    'single'     => null,
2576
                    'correct'    => [],
2577
                ];
2578
2579
                if ('multichoice' === $qtype) {
2580
                    $single = (int) ($qn->getElementsByTagName('single')->item(0)?->nodeValue ?? 1);
2581
                    $q['single'] = $single;
2582
2583
                    foreach ($qn->getElementsByTagName('answer') as $an) {
2584
                        /** @var DOMElement $an */
2585
                        $t = (string) ($an->getElementsByTagName('text')->item(0)?->nodeValue ?? '');
2586
                        $f = (float) ($an->getAttribute('fraction') ?: 0);
2587
                        $q['answers'][] = ['text' => $t, 'fraction' => $f];
2588
                        if ($f > 0) {
2589
                            $q['correct'][] = $t;
2590
                        }
2591
                    }
2592
                } elseif ('truefalse' === $qtype) {
2593
                    foreach ($qn->getElementsByTagName('answer') as $an) {
2594
                        $t = (string) ($an->getElementsByTagName('text')->item(0)?->nodeValue ?? '');
2595
                        $f = (float) ($an->getAttribute('fraction') ?: 0);
2596
                        $q['answers'][] = ['text' => $t, 'fraction' => $f];
2597
                        if ($f > 0) {
2598
                            $q['correct'][] = $t;
2599
                        }
2600
                    }
2601
                } // else: keep minimal info
2602
2603
                $questions[] = $q;
2604
            }
2605
        }
2606
2607
        return [$quiz, $questions];
2608
    }
2609
2610
    private function readAssignModule(string $xmlPath): array
2611
    {
2612
        $doc = $this->loadXml($xmlPath);
2613
        $xp  = new DOMXPath($doc);
2614
2615
        $name     = (string) ($xp->query('//assign/name')->item(0)?->nodeValue ?? 'Assignment');
2616
        $intro    = (string) ($xp->query('//assign/intro')->item(0)?->nodeValue ?? '');
2617
        $duedate  = (int) ($xp->query('//assign/duedate')->item(0)?->nodeValue ?? 0);
2618
        $allowsub = (int) ($xp->query('//assign/teamsubmission')->item(0)?->nodeValue ?? 0);
2619
2620
        return [
2621
            'name'        => $name,
2622
            'description' => $intro,
2623
            'deadline'    => $duedate,
2624
            'group'       => $allowsub,
2625
        ];
2626
    }
2627
2628
    private function readSurveyModule(string $xmlPath, string $type): array
2629
    {
2630
        $doc = $this->loadXml($xmlPath);
2631
        $xp  = new DOMXPath($doc);
2632
2633
        $name  = (string) ($xp->query("//{$type}/name")->item(0)?->nodeValue ?? ucfirst($type));
2634
        $intro = (string) ($xp->query("//{$type}/intro")->item(0)?->nodeValue ?? '');
2635
2636
        return [
2637
            'name'        => $name,
2638
            'subtitle'    => '',
2639
            'intro'       => $intro,
2640
            'thanks'      => '',
2641
            'survey_type' => $type,
2642
        ];
2643
    }
2644
2645
    private function readGlossaryModule(string $xmlPath): array
2646
    {
2647
        $doc = $this->loadXml($xmlPath);
2648
        $xp  = new DOMXPath($doc);
2649
2650
        $name  = (string) ($xp->query('//glossary/name')->item(0)?->nodeValue ?? 'Glossary');
2651
        $intro = (string) ($xp->query('//glossary/intro')->item(0)?->nodeValue ?? '');
2652
2653
        $entries = [];
2654
        foreach ($xp->query('//glossary/entries/entry') as $eNode) {
2655
            /** @var DOMElement $eNode */
2656
            $entryId    = (int) $eNode->getAttribute('id');
2657
            $concept    = trim((string) ($xp->evaluate('string(concept)', $eNode) ?? ''));
2658
            $definition = (string) ($xp->evaluate('string(definition)', $eNode) ?? '');
2659
            $approved   = (int) $xp->evaluate('number(approved)', $eNode);
2660
            $userId     = (int) $xp->evaluate('number(userid)', $eNode);
2661
            $created    = (int) $xp->evaluate('number(timecreated)', $eNode);
2662
            $modified   = (int) $xp->evaluate('number(timemodified)', $eNode);
2663
2664
            // Collect aliases
2665
            $aliases = [];
2666
            foreach ($xp->query('aliases/alias/alias_text', $eNode) as $aNode) {
2667
                $aliases[] = (string) $aNode->nodeValue;
2668
            }
2669
2670
            $entries[] = [
2671
                'id'          => $entryId,
2672
                'concept'     => $concept,
2673
                'definition'  => $definition, // keep HTML; resolver for @@PLUGINFILE@@ can run later
2674
                'approved'    => $approved ?: 1,
2675
                'userid'      => $userId,
2676
                'timecreated' => $created,
2677
                'timemodified'=> $modified,
2678
                'aliases'     => $aliases,
2679
            ];
2680
        }
2681
2682
        return [
2683
            'name'        => $name,
2684
            'description' => $intro,
2685
            'entries'     => $entries,
2686
        ];
2687
    }
2688
2689
    /**
2690
     * Read course-level events from /course/calendar.xml (as written by CourseExport).
2691
     * Returns legacy-shaped payloads for the 'events' bag.
2692
     *
2693
     * @return array<int,array<string,mixed>>
2694
     */
2695
    private function readCourseEvents(string $workDir): array
2696
    {
2697
        $path = rtrim($workDir, '/').'/course/calendar.xml';
2698
        if (!is_file($path)) {
2699
            // No calendar file -> no events
2700
            return [];
2701
        }
2702
2703
        // Load XML safely
2704
        $doc = new \DOMDocument('1.0', 'UTF-8');
2705
        $doc->preserveWhiteSpace = false;
2706
        $doc->formatOutput = false;
2707
2708
        $prev = libxml_use_internal_errors(true);
2709
        $ok = @$doc->load($path);
2710
        libxml_clear_errors();
2711
        libxml_use_internal_errors($prev);
2712
2713
        if (!$ok) {
2714
            // Corrupted calendar.xml -> ignore
2715
            return [];
2716
        }
2717
2718
        $xp = new \DOMXPath($doc);
2719
        $evNodes = $xp->query('/calendar/event');
2720
        if (!$evNodes || $evNodes->length === 0) {
0 ignored issues
show
introduced by
$evNodes is of type DOMNodeList, thus it always evaluated to true.
Loading history...
2721
            return [];
2722
        }
2723
2724
        $out = [];
2725
        /** @var \DOMElement $ev */
2726
        foreach ($evNodes as $ev) {
2727
            $get = static function (\DOMElement $ctx, string $tag): ?string {
2728
                $n = $ctx->getElementsByTagName($tag)->item(0);
2729
                return $n ? (string) $n->nodeValue : null; // preserves CDATA inner content
2730
            };
2731
2732
            // Fields per CourseExport::createCalendarXml()
2733
            $name       = trim((string) ($get($ev, 'name') ?? ''));
2734
            $desc       = (string) ($get($ev, 'description') ?? '');
2735
            $timestartV = (string) ($get($ev, 'timestart') ?? '');
2736
            $durationV  = (string) ($get($ev, 'duration') ?? '');
2737
            $alldayV    = (string) ($get($ev, 'allday') ?? '');
2738
            $visibleV   = (string) ($get($ev, 'visible') ?? '1');
2739
            $eventtype  = (string) ($get($ev, 'eventtype') ?? 'course');
2740
            $uuid       = (string) ($get($ev, 'uuid') ?? '');
2741
2742
            // Tolerant parsing: accept numeric or date-string (older/other writers)
2743
            $toTs = static function (string $v, int $fallback = 0): int {
2744
                if ($v === '') { return $fallback; }
2745
                if (is_numeric($v)) { return (int) $v; }
2746
                $t = @\strtotime($v);
2747
                return $t !== false ? (int) $t : $fallback;
2748
            };
2749
2750
            $timestart = $toTs($timestartV, time());
2751
            $duration  = max(0, (int) $durationV);
2752
            $allday    = (int) $alldayV ? 1 : 0;
2753
            $visible   = (int) $visibleV ? 1 : 0;
2754
2755
            // Legacy-friendly payload (used by mkLegacyItem('events', ...))
2756
            $payload = [
2757
                'name'        => $name !== '' ? $name : 'Event',
2758
                'description' => $desc,          // HTML allowed (comes from CDATA)
2759
                'timestart'   => $timestart,     // Unix timestamp
2760
                'duration'    => $duration,      // seconds
2761
                'allday'      => $allday,        // 0/1
2762
                'visible'     => $visible,       // 0/1
2763
                'eventtype'   => $eventtype,     // 'course' by default
2764
                'uuid'        => $uuid,          // $@NULL@$ or value
2765
            ];
2766
2767
            $out[] = $payload;
2768
        }
2769
2770
        return $out;
2771
    }
2772
2773
    /**
2774
     * Restore selected buckets using the generic CourseRestorer to persist them.
2775
     * This lets us reuse the legacy snapshot you already build.
2776
     */
2777
    private function restoreWithRestorer(
2778
        string $archivePath,
2779
        EntityManagerInterface $em,
2780
        int $courseRealId,
2781
        int $sessionId,
2782
        array $allowedBuckets,          // ej: ['quizzes','quiz_question']
2783
        ?object $courseArg = null
2784
    ): array {
2785
        $legacy = $courseArg ?: $this->buildLegacyCourseFromMoodleArchive($archivePath);
2786
        $legacy->resources = isset($legacy->resources) && \is_array($legacy->resources)
2787
            ? $this->canonicalizeResourceBags($legacy->resources)
2788
            : [];
2789
2790
        $expanded = $this->expandBucketAliases($allowedBuckets);
2791
        $legacy->resources = array_intersect_key(
2792
            (array) $legacy->resources,
2793
            array_flip($expanded)
2794
        );
2795
2796
        $total = 0;
2797
        foreach ($legacy->resources as $v) {
2798
            $total += \is_array($v) ? \count($v) : 0;
2799
        }
2800
        if ($total === 0) {
2801
            return ['imported' => 0, 'notes' => ['No resources to restore for '.implode(',', $allowedBuckets)]];
2802
        }
2803
2804
        $restorerClass = '\\Chamilo\\CourseBundle\\Component\\CourseCopy\\CourseRestorer';
2805
        if (!\class_exists($restorerClass)) {
2806
            return ['imported' => 0, 'notes' => ['CourseRestorer not available']];
2807
        }
2808
        $restorer = new $restorerClass($em, $courseRealId, $sessionId);
2809
2810
        if (property_exists($restorer, 'course')) {
2811
            $restorer->course = $legacy;
2812
        } elseif (\method_exists($restorer, 'setCourse')) {
2813
            $restorer->setCourse($legacy);
2814
        }
2815
2816
        $destCode = '';
2817
        $courseEntity = \function_exists('api_get_course_entity') ? api_get_course_entity($courseRealId) : null;
2818
        if ($courseEntity && \method_exists($courseEntity, 'getCode')) {
2819
            $destCode = (string) $courseEntity->getCode();
2820
        } else {
2821
            $ci = \function_exists('api_get_course_info_by_id') ? api_get_course_info_by_id($courseRealId) : null;
2822
            if (\is_array($ci) && !empty($ci['code'])) {
2823
                $destCode = (string) $ci['code'];
2824
            }
2825
        }
2826
2827
        if (\method_exists($restorer, 'restore')) {
2828
            $restorer->restore($destCode, $sessionId, false, false);
2829
        } else {
2830
            return ['imported' => 0, 'notes' => ['No supported restore() method in CourseRestorer']];
2831
        }
2832
2833
        return ['imported' => $total, 'notes' => ['Restored: '.implode(',', $expanded)]];
2834
    }
2835
2836
    private function expandBucketAliases(array $buckets): array
2837
    {
2838
        $map = [
2839
            'quizzes'        => ['quizzes', 'quiz', \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : 'quiz'],
2840
            'quiz'           => ['quiz', 'quizzes', \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : 'quiz'],
2841
            'quiz_question'  => ['quiz_question', 'Exercise_Question', \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : 'quiz_question'],
2842
            'scorm'             => ['scorm', 'scorm_documents'],
2843
            'scorm_documents'   => ['scorm_documents', 'scorm'],
2844
            'document'       => ['document', 'Document'],
2845
            'forum'          => ['forum'],
2846
            'Forum_Category' => ['Forum_Category'],
2847
            'link'           => ['link'],
2848
            'Link_Category'  => ['Link_Category'],
2849
            'learnpath'          => ['learnpath'],
2850
            'learnpath_category' => ['learnpath_category'],
2851
            'thematic'    => ['thematic', \defined('RESOURCE_THEMATIC') ? (string) RESOURCE_THEMATIC : 'thematic'],
2852
            'attendance'  => ['attendance', \defined('RESOURCE_ATTENDANCE') ? (string) RESOURCE_ATTENDANCE : 'attendance'],
2853
            'gradebook'   => ['gradebook', 'Gradebook', \defined('RESOURCE_GRADEBOOK') ? (string) RESOURCE_GRADEBOOK : 'gradebook'],
2854
            'announcement' => array_values(array_unique(array_filter([
2855
                'announcement',
2856
                'news',
2857
                \defined('RESOURCE_ANNOUNCEMENT') ? (string) RESOURCE_ANNOUNCEMENT : null,
2858
            ]))),
2859
            'news' => ['news', 'announcement'],
2860
        ];
2861
2862
        $out = [];
2863
        foreach ($buckets as $b) {
2864
            $b = (string) $b;
2865
            $out = array_merge($out, $map[$b] ?? [$b]);
2866
        }
2867
2868
        return array_values(array_unique($out));
2869
    }
2870
2871
    /**
2872
     * Convenience: restore a set of specific buckets using the generic CourseRestorer.
2873
     * Keeps all logic inside MoodleImport (no new classes).
2874
     *
2875
     * @param string[] $buckets
2876
     * @return array{imported:int,notes:array}
2877
     */
2878
    public function restoreSelectedBuckets(
2879
        string $archivePath,
2880
        EntityManagerInterface $em,
2881
        int $courseRealId,
2882
        int $sessionId = 0,
2883
        array $buckets = [],
2884
        ?object $courseArg = null
2885
    ): array {
2886
        if (empty($buckets)) {
2887
            return ['imported' => 0, 'notes' => ['No buckets requested']];
2888
        }
2889
2890
        // Delegate to the generic restorer while filtering the snapshot to the requested buckets.
2891
        return $this->restoreWithRestorer(
2892
            $archivePath,
2893
            $em,
2894
            $courseRealId,
2895
            $sessionId,
2896
            $buckets,
2897
            $courseArg
2898
        );
2899
    }
2900
2901
    /** Quizzes (+ question bank minimal snapshot) */
2902
    public function restoreQuizzes(
2903
        string $archivePath,
2904
        EntityManagerInterface $em,
2905
        int $courseRealId,
2906
        int $sessionId = 0,
2907
        ?object $courseArg = null
2908
    ): array {
2909
        return $this->restoreSelectedBuckets(
2910
            $archivePath, $em, $courseRealId, $sessionId,
2911
            ['quizzes', 'quiz_question'],
2912
            $courseArg
2913
        );
2914
    }
2915
2916
    /** SCORM packages */
2917
    public function restoreScorm(
2918
        string $archivePath,
2919
        EntityManagerInterface $em,
2920
        int $courseRealId,
2921
        int $sessionId = 0,
2922
        ?object $courseArg = null
2923
    ): array {
2924
        // Keep both keys for maximum compatibility with CourseRestorer implementations
2925
        return $this->restoreSelectedBuckets(
2926
            $archivePath, $em, $courseRealId, $sessionId,
2927
            ['scorm_documents', 'scorm'],
2928
            $courseArg
2929
        );
2930
    }
2931
2932
    /**
2933
     * Preferred Learnpath importer using Chamilo sidecar JSON under chamilo/learnpath.
2934
     * Returns true if LPs (and categories) were imported from meta.
2935
     */
2936
    private function tryImportLearnpathMeta(string $workDir, array &$resources): bool
2937
    {
2938
        $base = rtrim($workDir, '/').'/chamilo/learnpath';
2939
        $indexFile = $base.'/index.json';
2940
        if (!is_file($indexFile)) {
2941
            return false; // No meta present -> fallback to sections
2942
        }
2943
2944
        $index = $this->readJsonFile($indexFile);
2945
        $cats  = $this->readJsonFile($base.'/categories.json');
2946
2947
        // 1) Ensure learnpath_category from meta (idempotent)
2948
        $existingCatIds = array_map('intval', array_keys((array) ($resources['learnpath_category'] ?? [])));
2949
        foreach ((array) ($cats['categories'] ?? []) as $c) {
2950
            $cid   = (int) ($c['id'] ?? 0);
2951
            $title = (string) ($c['title'] ?? '');
2952
            if ($cid <= 0) { continue; }
2953
            if (!\in_array($cid, $existingCatIds, true)) {
2954
                // Preserve category id from meta to simplify mapping
2955
                $resources['learnpath_category'][$cid] = $this->mkLegacyItem('learnpath_category', $cid, [
2956
                    'id'    => $cid,
2957
                    'name'  => $title,
2958
                    'title' => $title,
2959
                ]);
2960
                $existingCatIds[] = $cid;
2961
            }
2962
        }
2963
2964
        // 2) Build search indexes to resolve item "ref" into our freshly-built resource IDs
2965
        $idx = $this->buildResourceIndexes($resources);
2966
2967
        // 3) Import learnpaths
2968
        $imported = 0;
2969
        foreach ((array) ($index['learnpaths'] ?? []) as $row) {
2970
            $dir = (string) ($row['dir'] ?? '');
2971
            if ($dir === '') { continue; }
2972
2973
            $lpJson   = $this->readJsonFile($base.'/'.$dir.'/learnpath.json');
2974
            $itemsJson= $this->readJsonFile($base.'/'.$dir.'/items.json');
2975
2976
            $lpRaw    = (array) ($lpJson['learnpath'] ?? []);
2977
            // Defensive normalization
2978
            $lpTitle  = (string) ($lpRaw['title'] ?? $lpRaw['name'] ?? ($row['title'] ?? 'Lesson'));
2979
            $lpType   = (int)   ($lpRaw['lp_type'] ?? $row['lp_type'] ?? 1);
2980
            $catId    = (int)   ($lpRaw['category_id'] ?? $row['category_id'] ?? 0);
2981
2982
            // Allocate a fresh ID (avoid collisions) but keep source id in meta
2983
            $lid = $this->nextId($resources['learnpath']);
2984
            $payload = [
2985
                'id'          => $lid,
2986
                'lp_type'     => $lpType, // 1=LP, 2=SCORM, 3=AICC
2987
                'title'       => $lpTitle,
2988
                'path'        => (string) ($lpRaw['path'] ?? ''),
2989
                'ref'         => (string) ($lpRaw['ref'] ?? ''),
2990
                'description' => (string) ($lpRaw['description'] ?? ''),
2991
                'content_local'      => (string) ($lpRaw['content_local'] ?? ''),
2992
                'default_encoding'   => (string) ($lpRaw['default_encoding'] ?? ''),
2993
                'default_view_mod'   => (string) ($lpRaw['default_view_mod'] ?? ''),
2994
                'prevent_reinit'     => (bool)   ($lpRaw['prevent_reinit'] ?? false),
2995
                'force_commit'       => (bool)   ($lpRaw['force_commit'] ?? false),
2996
                'content_maker'      => (string) ($lpRaw['content_maker'] ?? ''),
2997
                'display_order'      => (int)    ($lpRaw['display_order'] ?? 0),
2998
                'js_lib'             => (string) ($lpRaw['js_lib'] ?? ''),
2999
                'content_license'    => (string) ($lpRaw['content_license'] ?? ''),
3000
                'debug'              => (bool)   ($lpRaw['debug'] ?? false),
3001
                'visibility'         => (string) ($lpRaw['visibility'] ?? '1'),
3002
                'author'             => (string) ($lpRaw['author'] ?? ''),
3003
                'use_max_score'      => (int)    ($lpRaw['use_max_score'] ?? 0),
3004
                'autolaunch'         => (int)    ($lpRaw['autolaunch'] ?? 0),
3005
                'created_on'         => (string) ($lpRaw['created_on'] ?? ''),
3006
                'modified_on'        => (string) ($lpRaw['modified_on'] ?? ''),
3007
                'published_on'       => (string) ($lpRaw['published_on'] ?? ''),
3008
                'expired_on'         => (string) ($lpRaw['expired_on'] ?? ''),
3009
                'session_id'         => 0,
3010
                'category_id'        => $catId > 0 ? $catId : (array_key_first($resources['learnpath_category']) ?? 0),
3011
                '_src'               => [
3012
                    'lp_id' => (int) ($lpRaw['id'] ?? ($row['id'] ?? 0)),
3013
                ],
3014
            ];
3015
3016
            // Create wrapper with extended props (items, linked_resources)
3017
            $resources['learnpath'][$lid] = $this->mkLegacyItem('learnpath', $lid, $payload, ['items','linked_resources']);
3018
3019
            // Items: stable-order by display_order if present
3020
            $rawItems = (array) ($itemsJson['items'] ?? []);
3021
            usort($rawItems, static fn(array $a, array $b) =>
3022
                (int)($a['display_order'] ?? 0) <=> (int)($b['display_order'] ?? 0));
3023
3024
            $items = [];
3025
            foreach ($rawItems as $it) {
3026
                $mappedRef = $this->mapLpItemRef($it, $idx, $resources);
3027
                $items[] = [
3028
                    'id'             => (int)   ($it['id'] ?? 0),
3029
                    'item_type'      => (string)($it['item_type'] ?? ''),
3030
                    'ref'            => $mappedRef,
3031
                    'title'          => (string)($it['title'] ?? ''),
3032
                    'name'           => (string)($it['name'] ?? $lpTitle),
3033
                    'description'    => (string)($it['description'] ?? ''),
3034
                    'path'           => (string)($it['path'] ?? ''),
3035
                    'min_score'      => (float) ($it['min_score'] ?? 0),
3036
                    'max_score'      => isset($it['max_score']) ? (float) $it['max_score'] : null,
3037
                    'mastery_score'  => isset($it['mastery_score']) ? (float) $it['mastery_score'] : null,
3038
                    'parent_item_id' => (int)   ($it['parent_item_id'] ?? 0),
3039
                    'previous_item_id'=> isset($it['previous_item_id']) ? (int) $it['previous_item_id'] : null,
3040
                    'next_item_id'   => isset($it['next_item_id']) ? (int) $it['next_item_id'] : null,
3041
                    'display_order'  => (int)   ($it['display_order'] ?? 0),
3042
                    'prerequisite'   => (string)($it['prerequisite'] ?? ''),
3043
                    'parameters'     => (string)($it['parameters'] ?? ''),
3044
                    'launch_data'    => (string)($it['launch_data'] ?? ''),
3045
                    'audio'          => (string)($it['audio'] ?? ''),
3046
                    '_src'           => [
3047
                        'ref'  => $it['ref'] ?? null,
3048
                        'path' => $it['path'] ?? null,
3049
                    ],
3050
                ];
3051
            }
3052
3053
            $resources['learnpath'][$lid]->items = $items;
3054
            $resources['learnpath'][$lid]->linked_resources = $this->collectLinkedFromLpItems($items);
3055
3056
            $imported++;
3057
        }
3058
3059
        if ($this->debug) {
3060
            @error_log("MOODLE_IMPORT: LPs from meta imported={$imported}");
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). 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

3060
            /** @scrutinizer ignore-unhandled */ @error_log("MOODLE_IMPORT: LPs from meta imported={$imported}");

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...
3061
        }
3062
        return $imported > 0;
3063
    }
3064
3065
    /** Read JSON file safely; return [] on error. */
3066
    private function readJsonFile(string $file): array
3067
    {
3068
        $raw = @file_get_contents($file);
3069
        if ($raw === false) { return []; }
3070
        $data = json_decode($raw, true);
3071
        return \is_array($data) ? $data : [];
3072
    }
3073
3074
    /**
3075
     * Build look-up indexes over the freshly collected resources to resolve LP item refs.
3076
     * We index by multiple keys to increase match odds (path, title, url, etc.)
3077
     */
3078
    private function buildResourceIndexes(array $resources): array
3079
    {
3080
        $idx = [
3081
            'documentByPath' => [],
3082
            'documentByTitle' => [],
3083
            'linkByUrl' => [],
3084
            'forumByTitle' => [],
3085
            'quizByTitle' => [],
3086
            'workByTitle' => [],
3087
            'scormByTitle' => [],
3088
        ];
3089
3090
        foreach ((array) ($resources['document'] ?? []) as $id => $doc) {
3091
            $arr = \is_object($doc) ? get_object_vars($doc) : (array) $doc;
3092
            $p   = (string) ($arr['path'] ?? '');
3093
            $t   = (string) ($arr['title'] ?? '');
3094
            if ($p !== '') { $idx['documentByPath'][$p] = (int) $id; }
3095
            if ($t !== '') { $idx['documentByTitle'][mb_strtolower($t)][] = (int) $id; }
3096
        }
3097
        foreach ((array) ($resources['link'] ?? []) as $id => $lnk) {
3098
            $arr = \is_object($lnk) ? get_object_vars($lnk) : (array) $lnk;
3099
            $u   = (string) ($arr['url'] ?? '');
3100
            if ($u !== '') { $idx['linkByUrl'][$u] = (int) $id; }
3101
        }
3102
        foreach ((array) ($resources['forum'] ?? []) as $id => $f) {
3103
            $arr = \is_object($f) ? get_object_vars($f) : (array) $f;
3104
            $t   = (string) ($arr['forum_title'] ?? $arr['title'] ?? '');
3105
            if ($t !== '') { $idx['forumByTitle'][mb_strtolower($t)][] = (int) $id; }
3106
        }
3107
        foreach ((array) ($resources['quizzes'] ?? []) as $id => $q) {
3108
            $arr = \is_object($q) ? get_object_vars($q) : (array) $q;
3109
            $t   = (string) ($arr['name'] ?? $arr['title'] ?? '');
3110
            if ($t !== '') { $idx['quizByTitle'][mb_strtolower($t)][] = (int) $id; }
3111
        }
3112
        foreach ((array) ($resources['works'] ?? []) as $id => $w) {
3113
            $arr = \is_object($w) ? get_object_vars($w) : (array) $w;
3114
            $t   = (string) ($arr['name'] ?? $arr['title'] ?? '');
3115
            if ($t !== '') { $idx['workByTitle'][mb_strtolower($t)][] = (int) $id; }
3116
        }
3117
        foreach ((array) ($resources['scorm'] ?? $resources['scorm_documents'] ?? []) as $id => $s) {
3118
            $arr = \is_object($s) ? get_object_vars($s) : (array) $s;
3119
            $t   = (string) ($arr['title'] ?? $arr['name'] ?? '');
3120
            if ($t !== '') { $idx['scormByTitle'][mb_strtolower($t)][] = (int) $id; }
3121
        }
3122
3123
        return $idx;
3124
    }
3125
3126
    /**
3127
     * Resolve LP item "ref" from meta (which refers to the source system) into a local resource id.
3128
     * Strategy:
3129
     *  1) For documents: match by path (strong), or by title (weak).
3130
     *  2) For links: match by url.
3131
     *  3) For forum/quizzes/works/scorm: match by title.
3132
     * If not resolvable, return null and keep original _src in item for later diagnostics.
3133
     */
3134
    private function mapLpItemRef(array $item, array $idx, array $resources): ?int
3135
    {
3136
        $type = (string) ($item['item_type'] ?? '');
3137
        $srcRef = $item['ref'] ?? null;
3138
        $path   = (string) ($item['path'] ?? '');
3139
        $title  = mb_strtolower((string) ($item['title'] ?? ''));
3140
3141
        switch ($type) {
3142
            case 'document':
3143
                if ($path !== '' && isset($idx['documentByPath'][$path])) {
3144
                    return $idx['documentByPath'][$path];
3145
                }
3146
                if ($title !== '' && !empty($idx['documentByTitle'][$title])) {
3147
                    // If multiple, pick the first; could be improved with size/hash if available
3148
                    return $idx['documentByTitle'][$title][0];
3149
                }
3150
                return null;
3151
3152
            case 'link':
3153
                if (isset($idx['linkByUrl'][$srcRef])) {
3154
                    return $idx['linkByUrl'][$srcRef];
3155
                }
3156
                // Sometimes meta keeps URL in "path"
3157
                if ($path !== '' && isset($idx['linkByUrl'][$path])) {
3158
                    return $idx['linkByUrl'][$path];
3159
                }
3160
                return null;
3161
3162
            case 'forum':
3163
                if ($title !== '' && !empty($idx['forumByTitle'][$title])) {
3164
                    return $idx['forumByTitle'][$title][0];
3165
                }
3166
                return null;
3167
3168
            case 'quiz':
3169
            case 'quizzes':
3170
                if ($title !== '' && !empty($idx['quizByTitle'][$title])) {
3171
                    return $idx['quizByTitle'][$title][0];
3172
                }
3173
                return null;
3174
3175
            case 'works':
3176
                if ($title !== '' && !empty($idx['workByTitle'][$title])) {
3177
                    return $idx['workByTitle'][$title][0];
3178
                }
3179
                return null;
3180
3181
            case 'scorm':
3182
                if ($title !== '' && !empty($idx['scormByTitle'][$title])) {
3183
                    return $idx['scormByTitle'][$title][0];
3184
                }
3185
                return null;
3186
3187
            default:
3188
                return null;
3189
        }
3190
    }
3191
3192
    /**
3193
    " Import quizzes from QuizMetaExport sidecars under chamilo/quiz/quiz_.
3194
    * Builds 'quiz' and 'quiz_question' (and their constant-key aliases if defined).
3195
    * Returns true if at least one quiz was imported.
3196
    */
3197
    private function tryImportQuizMeta(string $workDir, array &$resources): bool
3198
    {
3199
        $base = rtrim($workDir, '/').'/chamilo/quiz';
3200
        if (!is_dir($base)) {
3201
            return false;
3202
        }
3203
3204
        // Resolve resource keys (support both constant and string bags)
3205
        $quizKey = \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz';
3206
        // Chamilo snapshot also uses sometimes 'quizzes' — we fill both for compatibility
3207
        $quizCompatKey = 'quizzes';
3208
3209
        $qqKey = \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : 'Exercise_Question';
3210
        $qqCompatKey = 'quiz_question';
3211
3212
        $imported = 0;
3213
3214
        // Iterate all quiz_* folders
3215
        $dh = @opendir($base);
3216
        if (!$dh) {
0 ignored issues
show
introduced by
$dh is of type false|resource, thus it always evaluated to false.
Loading history...
3217
            return false;
3218
        }
3219
3220
        while (false !== ($entry = readdir($dh))) {
3221
            if ($entry === '.' || $entry === '..') {
3222
                continue;
3223
            }
3224
            $dir = $base.'/'.$entry;
3225
            if (!is_dir($dir) || strpos($entry, 'quiz_') !== 0) {
3226
                continue;
3227
            }
3228
3229
            $quizJsonFile = $dir.'/quiz.json';
3230
            $questionsFile = $dir.'/questions.json';
3231
            $answersFile   = $dir.'/answers.json'; // optional (flat; we prefer nested)
3232
3233
            $quizWrap = $this->readJsonFile($quizJsonFile);
3234
            $qList    = $this->readJsonFile($questionsFile);
3235
            if (empty($quizWrap) || empty($qList)) {
3236
                // Nothing to import for this folder
3237
                if ($this->debug) {
3238
                    @error_log("MOODLE_IMPORT: Quiz meta missing or incomplete in {$entry}");
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). 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

3238
                    /** @scrutinizer ignore-unhandled */ @error_log("MOODLE_IMPORT: Quiz meta missing or incomplete in {$entry}");

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...
3239
                }
3240
                continue;
3241
            }
3242
3243
            $quizArr = (array) ($quizWrap['quiz'] ?? []);
3244
            $questions = (array) ($qList['questions'] ?? []);
3245
3246
            // ---- Resolve or allocate quiz local id
3247
            $title = (string) ($quizArr['title'] ?? $quizArr['name'] ?? 'Quiz');
3248
            $qidLocal = $this->findExistingQuizIdByTitle($resources, $title, [$quizKey, $quizCompatKey]);
3249
            if (!$qidLocal) {
3250
                $qidLocal = $this->nextId($resources[$quizKey] ?? []);
3251
            }
3252
3253
            // Ensure bags exist
3254
            if (!isset($resources[$quizKey]))      { $resources[$quizKey] = []; }
3255
            if (!isset($resources[$quizCompatKey])){ $resources[$quizCompatKey] = []; }
3256
            if (!isset($resources[$qqKey]))        { $resources[$qqKey] = []; }
3257
            if (!isset($resources[$qqCompatKey]))  { $resources[$qqCompatKey] = []; }
3258
3259
            // ---- Build local question id map (src → local)
3260
            $srcToLocalQ = [];
3261
            // If meta provides question_ids, we keep order from 'question_orders' when present.
3262
            $srcIds   = array_map('intval', (array) ($quizArr['question_ids'] ?? []));
3263
            $srcOrder = array_map('intval', (array) ($quizArr['question_orders'] ?? []));
3264
3265
            // First pass: assign local ids to all questions we are about to import
3266
            foreach ($questions as $qArr) {
3267
                // Prefer explicit id added by exporter; otherwise try _links.quiz_id or fallback 0
3268
                $srcQid = (int) ($qArr['id'] ?? 0);
3269
                if ($srcQid <= 0) {
3270
                    // Try to infer from position in the list (not ideal, but keeps import going)
3271
                    $srcQid = $this->nextId($srcToLocalQ); // synthetic progressive id
3272
                }
3273
                $srcToLocalQ[$srcQid] = $this->nextId($resources[$qqKey]);
3274
            }
3275
3276
            // ---- Rebuild quiz payload (builder-compatible)
3277
            $payload = [
3278
                'title'                 => (string) ($quizArr['title'] ?? ''),
3279
                'description'           => (string) ($quizArr['description'] ?? ''),
3280
                'type'                  => (int)    ($quizArr['type'] ?? 0),
3281
                'random'                => (int)    ($quizArr['random'] ?? 0),
3282
                'random_answers'        => (bool)   ($quizArr['random_answers'] ?? false),
3283
                'results_disabled'      => (int)    ($quizArr['results_disabled'] ?? 0),
3284
                'max_attempt'           => (int)    ($quizArr['max_attempt'] ?? 0),
3285
                'feedback_type'         => (int)    ($quizArr['feedback_type'] ?? 0),
3286
                'expired_time'          => (int)    ($quizArr['expired_time'] ?? 0),
3287
                'review_answers'        => (int)    ($quizArr['review_answers'] ?? 0),
3288
                'random_by_category'    => (int)    ($quizArr['random_by_category'] ?? 0),
3289
                'text_when_finished'    => (string) ($quizArr['text_when_finished'] ?? ''),
3290
                'text_when_finished_failure' => (string) ($quizArr['text_when_finished_failure'] ?? ''),
3291
                'display_category_name' => (int)    ($quizArr['display_category_name'] ?? 0),
3292
                'save_correct_answers'  => (int)    ($quizArr['save_correct_answers'] ?? 0),
3293
                'propagate_neg'         => (int)    ($quizArr['propagate_neg'] ?? 0),
3294
                'hide_question_title'   => (bool)   ($quizArr['hide_question_title'] ?? false),
3295
                'hide_question_number'  => (int)    ($quizArr['hide_question_number'] ?? 0),
3296
                'question_selection_type'=> (int)   ($quizArr['question_selection_type'] ?? 0),
3297
                'access_condition'      => (string) ($quizArr['access_condition'] ?? ''),
3298
                'pass_percentage'       => $quizArr['pass_percentage'] ?? null,
3299
                'start_time'            => (string) ($quizArr['start_time'] ?? ''),
3300
                'end_time'              => (string) ($quizArr['end_time'] ?? ''),
3301
                // We will remap IDs to locals below:
3302
                'question_ids'          => [],
3303
                'question_orders'       => [],
3304
            ];
3305
3306
            // Fill question_ids and orders using the local id map
3307
            $localIds   = [];
3308
            $localOrder = [];
3309
3310
            // If we received aligned srcIds/srcOrder, keep that order; otherwise, use question '_order'
3311
            if (!empty($srcIds) && !empty($srcOrder) && \count($srcIds) === \count($srcOrder)) {
3312
                foreach ($srcIds as $i => $srcQid) {
3313
                    if (isset($srcToLocalQ[$srcQid])) {
3314
                        $localIds[]   = $srcToLocalQ[$srcQid];
3315
                        $localOrder[] = (int) $srcOrder[$i];
3316
                    }
3317
                }
3318
            } else {
3319
                // Build order from questions array (_order) if present; otherwise keep list order
3320
                usort($questions, static fn(array $a, array $b) =>
3321
                    (int)($a['_order'] ?? 0) <=> (int)($b['_order'] ?? 0));
3322
                foreach ($questions as $qArr) {
3323
                    $srcQid = (int) ($qArr['id'] ?? 0);
3324
                    if (isset($srcToLocalQ[$srcQid])) {
3325
                        $localIds[]   = $srcToLocalQ[$srcQid];
3326
                        $localOrder[] = (int) ($qArr['_order'] ?? 0);
3327
                    }
3328
                }
3329
            }
3330
3331
            $payload['question_ids']    = $localIds;
3332
            $payload['question_orders'] = $localOrder;
3333
3334
            // Store quiz in both bags (constant/string and compat)
3335
            $resources[$quizKey][$qidLocal] =
3336
                $this->mkLegacyItem($quizKey, $qidLocal, $payload, ['question_ids', 'question_orders']);
3337
            $resources[$quizCompatKey][$qidLocal] = $resources[$quizKey][$qidLocal];
3338
3339
            // ---- Import questions (with nested answers), mapping to local ids
3340
            foreach ($questions as $qArr) {
3341
                $srcQid = (int) ($qArr['id'] ?? 0);
3342
                $qid    = $srcToLocalQ[$srcQid] ?? $this->nextId($resources[$qqKey]);
3343
3344
                $qPayload = [
3345
                    'question'        => (string) ($qArr['question'] ?? ''),
3346
                    'description'     => (string) ($qArr['description'] ?? ''),
3347
                    'ponderation'     => (float)  ($qArr['ponderation'] ?? 0),
3348
                    'position'        => (int)    ($qArr['position'] ?? 0),
3349
                    'type'            => (int)    ($qArr['type'] ?? ($qArr['quiz_type'] ?? 0)),
3350
                    'quiz_type'       => (int)    ($qArr['quiz_type'] ?? ($qArr['type'] ?? 0)),
3351
                    'picture'         => (string) ($qArr['picture'] ?? ''),
3352
                    'level'           => (int)    ($qArr['level'] ?? 0),
3353
                    'extra'           => (string) ($qArr['extra'] ?? ''),
3354
                    'feedback'        => (string) ($qArr['feedback'] ?? ''),
3355
                    'question_code'   => (string) ($qArr['question_code'] ?? ''),
3356
                    'mandatory'       => (int)    ($qArr['mandatory'] ?? 0),
3357
                    'duration'        => $qArr['duration'] ?? null,
3358
                    'parent_media_id' => $qArr['parent_media_id'] ?? null,
3359
                    'answers'         => [],
3360
                ];
3361
3362
                // Answers: prefer nested in questions.json; fallback to answers.json (flat)
3363
                $ansList = [];
3364
                if (isset($qArr['answers']) && \is_array($qArr['answers'])) {
3365
                    $ansList = $qArr['answers'];
3366
                } else {
3367
                    // Try to reconstruct from flat answers.json
3368
                    $ansFlat = $this->readJsonFile($answersFile);
3369
                    foreach ((array) ($ansFlat['answers'] ?? []) as $row) {
3370
                        if ((int) ($row['question_id'] ?? -1) === $srcQid && isset($row['data'])) {
3371
                            $ansList[] = $row['data'];
3372
                        }
3373
                    }
3374
                }
3375
3376
                $pos = 1;
3377
                foreach ($ansList as $a) {
3378
                    $qPayload['answers'][] = [
3379
                        'id'                  => (int)    ($a['id'] ?? $this->nextId($qPayload['answers'])),
3380
                        'answer'              => (string) ($a['answer'] ?? ''),
3381
                        'comment'             => (string) ($a['comment'] ?? ''),
3382
                        'ponderation'         => (float)  ($a['ponderation'] ?? 0),
3383
                        'position'            => (int)    ($a['position'] ?? $pos),
3384
                        'hotspot_coordinates' => $a['hotspot_coordinates'] ?? null,
3385
                        'hotspot_type'        => $a['hotspot_type'] ?? null,
3386
                        'correct'             => $a['correct'] ?? null,
3387
                    ];
3388
                    $pos++;
3389
                }
3390
3391
                // Optional: MATF options (as in builder)
3392
                if (isset($qArr['question_options']) && \is_array($qArr['question_options'])) {
3393
                    $qPayload['question_options'] = array_map(static fn ($o) => [
3394
                        'id'       => (int) ($o['id'] ?? 0),
3395
                        'name'     => (string) ($o['name'] ?? ''),
3396
                        'position' => (int) ($o['position'] ?? 0),
3397
                    ], $qArr['question_options']);
3398
                }
3399
3400
                $resources[$qqKey][$qid] =
3401
                    $this->mkLegacyItem($qqKey, $qid, $qPayload, ['answers', 'question_options']);
3402
                $resources[$qqCompatKey][$qid] = $resources[$qqKey][$qid];
3403
            }
3404
3405
            $imported++;
3406
        }
3407
3408
        closedir($dh);
3409
3410
        if ($this->debug) {
3411
            @error_log("MOODLE_IMPORT: Quizzes from meta imported={$imported}");
3412
        }
3413
        return $imported > 0;
3414
    }
3415
3416
    /** Find an existing quiz by title (case-insensitive) in any of the provided bags. */
3417
    private function findExistingQuizIdByTitle(array $resources, string $title, array $bags): ?int
3418
    {
3419
        $needle = mb_strtolower(trim($title));
3420
        foreach ($bags as $bag) {
3421
            foreach ((array) ($resources[$bag] ?? []) as $id => $q) {
3422
                $arr = \is_object($q) ? get_object_vars($q) : (array) $q;
3423
                $t   = (string) ($arr['title'] ?? $arr['name'] ?? '');
3424
                if ($needle !== '' && mb_strtolower($t) === $needle) {
3425
                    return (int) $id;
3426
                }
3427
            }
3428
        }
3429
        return null;
3430
    }
3431
3432
    /** Quick probe: do we have at least one chamilo/quiz/quiz_quiz.json + questions.json ? */
3433
    private function hasQuizMeta(string $workDir): bool
3434
    {
3435
        $base = rtrim($workDir, '/').'/chamilo/quiz';
3436
        if (!is_dir($base)) return false;
3437
        if (!$dh = @opendir($base)) return false;
3438
        while (false !== ($e = readdir($dh))) {
3439
            if ($e === '.' || $e === '..') continue;
3440
            $dir = $base.'/'.$e;
3441
            if (is_dir($dir) && str_starts_with($e, 'quiz_')
3442
                && is_file($dir.'/quiz.json') && is_file($dir.'/questions.json')) {
3443
                closedir($dh);
3444
                return true;
3445
            }
3446
        }
3447
        closedir($dh);
3448
        return false;
3449
    }
3450
3451
    /** Quick probe: typical LearnpathMetaExport artifacts */
3452
    private function hasLearnpathMeta(string $workDir): bool
3453
    {
3454
        $base = rtrim($workDir, '/').'/chamilo/learnpath';
3455
        return is_dir($base) && (is_file($base.'/index.json') || is_file($base.'/categories.json'));
3456
    }
3457
3458
    /** Cheap reader: obtain <quiz><name> from module xml without building resources. */
3459
    private function peekQuizTitle(string $moduleXml): ?string
3460
    {
3461
        try {
3462
            $doc = $this->loadXml($moduleXml);
3463
            $xp  = new DOMXPath($doc);
3464
            $name = $xp->query('//quiz/name')->item(0)?->nodeValue ?? null;
3465
            return $name ? (string) $name : null;
3466
        } catch (\Throwable) {
3467
            return null;
3468
        }
3469
    }
3470
3471
    /**
3472
     * For LP fallback items with missing 'ref', try to resolve by title against resources bags.
3473
     * Matching is case-insensitive and checks both 'title' and 'name' fields in resources.
3474
     */
3475
    private function backfillLpRefsFromResources(array &$lpMap, array $resources, array $bags): void
3476
    {
3477
        // Build lookup: item_type => [lower(title) => id]
3478
        $lookups = [];
3479
3480
        foreach ($bags as $bag) {
3481
            foreach ((array) ($resources[$bag] ?? []) as $id => $wrap) {
3482
                $obj = \is_object($wrap) ? $wrap : (object) $wrap;
3483
                // candidate fields
3484
                $title = '';
3485
                if (isset($obj->title) && \is_string($obj->title)) {
3486
                    $title = $obj->title;
3487
                } elseif (isset($obj->name) && \is_string($obj->name)) {
3488
                    $title = $obj->name;
3489
                } elseif (isset($obj->obj) && \is_object($obj->obj)) {
3490
                    $title = (string) ($obj->obj->title ?? $obj->obj->name ?? '');
3491
                }
3492
                if ($title === '') continue;
3493
3494
                $key = mb_strtolower($title);
3495
                $typeKey = $this->normalizeItemTypeKey($bag); // e.g. 'quiz' for ['quiz','quizzes']
3496
                if (!isset($lookups[$typeKey])) $lookups[$typeKey] = [];
3497
                $lookups[$typeKey][$key] = (int) $id;
3498
            }
3499
        }
3500
3501
        // Walk all LP items and fill 'ref' when empty
3502
        foreach ($lpMap as &$lp) {
3503
            foreach ($lp['items'] as &$it) {
3504
                if (!empty($it['ref'])) continue;
3505
                $type = $this->normalizeItemTypeKey((string) ($it['item_type'] ?? ''));
3506
                $t    = mb_strtolower((string) ($it['title'] ?? ''));
3507
                if ($t !== '' && isset($lookups[$type][$t])) {
3508
                    $it['ref'] = $lookups[$type][$t];
3509
                }
3510
            }
3511
            unset($it);
3512
        }
3513
        unset($lp);
3514
    }
3515
3516
    /** Normalize various bag/item_type labels into a stable key used in lookup. */
3517
    private function normalizeItemTypeKey(string $s): string
3518
    {
3519
        $s = strtolower($s);
3520
        return match ($s) {
3521
            'quizzes', 'quiz', \defined('RESOURCE_QUIZ') ? strtolower((string) RESOURCE_QUIZ) : 'quiz' => 'quiz',
3522
            'document', 'documents' => 'document',
3523
            'forum', 'forums' => 'forum',
3524
            'link', 'links' => 'link',
3525
            'scorm', 'scorm_documents' => 'scorm',
3526
            'coursedescription'   => 'course_description',
3527
            'course_descriptions' => 'course_description',
3528
            default => $s,
3529
        };
3530
    }
3531
3532
    /** Merge bag aliases into canonical keys to avoid duplicate groups. */
3533
    private function canonicalizeResourceBags(array $res): array
3534
    {
3535
        // Canonical keys (fall back to strings if constants not defined)
3536
        $QUIZ_KEY = \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz';
3537
        $QQ_KEY   = \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : 'quiz_question';
3538
3539
        // ---- Quizzes ----
3540
        $mergedQuiz = [];
3541
        foreach (['quizzes', 'quiz', 'Exercise', $QUIZ_KEY] as $k) {
3542
            if (!empty($res[$k]) && \is_array($res[$k])) {
3543
                foreach ($res[$k] as $id => $item) {
3544
                    $mergedQuiz[(int)$id] = $item;
3545
                }
3546
            }
3547
            unset($res[$k]);
3548
        }
3549
        $res[$QUIZ_KEY] = $mergedQuiz;
3550
3551
        // ---- Quiz Questions ----
3552
        $mergedQQ = [];
3553
        foreach (['quiz_question', 'Exercise_Question', $QQ_KEY] as $k) {
3554
            if (!empty($res[$k]) && \is_array($res[$k])) {
3555
                foreach ($res[$k] as $id => $item) {
3556
                    $mergedQQ[(int)$id] = $item;
3557
                }
3558
            }
3559
            unset($res[$k]);
3560
        }
3561
        $res[$QQ_KEY] = $mergedQQ;
3562
3563
        $THEM_KEY = \defined('RESOURCE_THEMATIC') ? RESOURCE_THEMATIC : 'thematic';
3564
        $mergedThematic = [];
3565
        foreach (['thematic', $THEM_KEY] as $k) {
3566
            if (!empty($res[$k]) && \is_array($res[$k])) {
3567
                foreach ($res[$k] as $id => $item) {
3568
                    $mergedThematic[(int)$id] = $item;
3569
                }
3570
            }
3571
            unset($res[$k]);
3572
        }
3573
        if (!empty($mergedThematic)) {
3574
            $res[$THEM_KEY] = $mergedThematic;
3575
        }
3576
3577
        $ATT_KEY = \defined('RESOURCE_ATTENDANCE') ? RESOURCE_ATTENDANCE : 'attendance';
3578
        $merged = [];
3579
        foreach (['attendance', $ATT_KEY] as $k) {
3580
            if (!empty($res[$k]) && \is_array($res[$k])) {
3581
                foreach ($res[$k] as $id => $it) { $merged[(int)$id] = $it; }
3582
            }
3583
            unset($res[$k]);
3584
        }
3585
        if ($merged) { $res[$ATT_KEY] = $merged; }
0 ignored issues
show
introduced by
$merged is an empty array, thus is always false.
Loading history...
Bug Best Practice introduced by
The expression $merged of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
3586
3587
        $GB_KEY = \defined('RESOURCE_GRADEBOOK') ? RESOURCE_GRADEBOOK : 'gradebook';
3588
        $merged = [];
3589
        foreach (['gradebook', 'Gradebook', $GB_KEY] as $k) {
3590
            if (!empty($res[$k]) && \is_array($res[$k])) {
3591
                foreach ($res[$k] as $id => $it) { $merged[(int)$id] = $it; }
3592
            }
3593
            unset($res[$k]);
3594
        }
3595
        if ($merged) { $res[$GB_KEY] = $merged; }
0 ignored issues
show
Bug Best Practice introduced by
The expression $merged of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
introduced by
$merged is an empty array, thus is always false.
Loading history...
3596
3597
        return $res;
3598
    }
3599
3600
    /**
3601
     * Import Thematic sidecars written by ThematicMetaExport.
3602
     * Authoritative: if present, replaces any pre-filled bag.
3603
     *
3604
     * @return bool true if at least one thematic was imported
3605
     */
3606
    private function tryImportThematicMeta(string $workDir, array &$resources): bool
3607
    {
3608
        $THEM_KEY = \defined('RESOURCE_THEMATIC') ? RESOURCE_THEMATIC : 'thematic';
3609
3610
        $base = rtrim($workDir, '/').'/chamilo/thematic';
3611
        if (!is_dir($base)) {
3612
            return false;
3613
        }
3614
3615
        // 1) Discover files (prefer manifest, fallback to glob)
3616
        $files = [];
3617
        $manifest = @json_decode((string)@file_get_contents(rtrim($workDir, '/').'/chamilo/manifest.json'), true);
3618
        if (\is_array($manifest['items'] ?? null)) {
3619
            foreach ($manifest['items'] as $it) {
3620
                if (($it['kind'] ?? '') === 'thematic' && !empty($it['path'])) {
3621
                    $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/');
3622
                    if (is_file($path)) {
3623
                        $files[] = $path;
3624
                    }
3625
                }
3626
            }
3627
        }
3628
        if (empty($files)) {
3629
            foreach ((array)@glob($base.'/thematic_*.json') as $f) {
3630
                if (is_file($f)) {
3631
                    $files[] = $f;
3632
                }
3633
            }
3634
        }
3635
        if (empty($files)) {
3636
            return false;
3637
        }
3638
3639
        // Authoritative: reset bag to avoid duplicates
3640
        $resources[$THEM_KEY] = [];
3641
3642
        $imported = 0;
3643
        foreach ($files as $f) {
3644
            $payload = @json_decode((string)@file_get_contents($f), true);
3645
            if (!\is_array($payload)) {
3646
                continue;
3647
            }
3648
3649
            // Exporter shape: { "type":"thematic", ..., "title","content","active","advances":[...], "plans":[...] }
3650
            $title   = (string)($payload['title']   ?? 'Thematic');
3651
            $content = (string)($payload['content'] ?? '');
3652
            $active  = (int)   ($payload['active']  ?? 1);
3653
3654
            // Prefer explicit id inside nested shapes if present
3655
            $iid = (int)($payload['id'] ?? 0);
3656
            if ($iid <= 0) {
3657
                // Derive from filename thematic_{moduleId}.json or moduleid
3658
                $iid = (int)($payload['moduleid'] ?? 0);
3659
                if ($iid <= 0 && preg_match('/thematic_(\d+)\.json$/', (string)$f, $m)) {
3660
                    $iid = (int)$m[1];
3661
                }
3662
                if ($iid <= 0) {
3663
                    $iid = $this->nextId($resources[$THEM_KEY] ?? []);
3664
                }
3665
            }
3666
3667
            // Normalize lists
3668
            $advances = [];
3669
            foreach ((array)($payload['advances'] ?? []) as $a) {
3670
                $a = (array)$a;
3671
                $advances[] = [
3672
                    'id'            => (int)   ($a['id'] ?? ($a['iid'] ?? 0)),
3673
                    'thematic_id'   => (int)   ($a['thematic_id'] ?? $iid),
3674
                    'content'       => (string)($a['content'] ?? ''),
3675
                    'start_date'    => (string)($a['start_date'] ?? ''),
3676
                    'duration'      => (int)   ($a['duration'] ?? 0),
3677
                    'done_advance'  => (bool)  ($a['done_advance'] ?? false),
3678
                    'attendance_id' => (int)   ($a['attendance_id'] ?? 0),
3679
                    'room_id'       => (int)   ($a['room_id'] ?? 0),
3680
                ];
3681
            }
3682
3683
            $plans = [];
3684
            foreach ((array)($payload['plans'] ?? []) as $p) {
3685
                $p = (array)$p;
3686
                $plans[] = [
3687
                    'id'               => (int)   ($p['id'] ?? ($p['iid'] ?? 0)),
3688
                    'thematic_id'      => (int)   ($p['thematic_id'] ?? $iid),
3689
                    'title'            => (string)($p['title'] ?? ''),
3690
                    'description'      => (string)($p['description'] ?? ''),
3691
                    'description_type' => (int)   ($p['description_type'] ?? 0),
3692
                ];
3693
            }
3694
3695
            // mkLegacyItem wrapper with explicit list fields
3696
            $item = $this->mkLegacyItem($THEM_KEY, $iid, [
3697
                'id'      => $iid,
3698
                'title'   => $title,
3699
                'content' => $content,
3700
                'active'  => $active,
3701
            ], ['thematic_advance_list','thematic_plan_list']);
3702
3703
            // Attach lists on the wrapper (builder-friendly)
3704
            $item->thematic_advance_list = $advances;
3705
            $item->thematic_plan_list    = $plans;
3706
3707
            $resources[$THEM_KEY][$iid] = $item;
3708
            $imported++;
3709
        }
3710
3711
        if ($this->debug) {
3712
            @error_log('MOODLE_IMPORT: Thematic meta imported='.$imported);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). 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

3712
            /** @scrutinizer ignore-unhandled */ @error_log('MOODLE_IMPORT: Thematic meta imported='.$imported);

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...
3713
        }
3714
3715
        return $imported > 0;
3716
    }
3717
3718
    /**
3719
     * Import Attendance sidecars written by AttendanceMetaExport.
3720
     * Authoritative: if present, replaces any pre-filled bag to avoid duplicates.
3721
     *
3722
     * @return bool true if at least one attendance was imported
3723
     */
3724
    private function tryImportAttendanceMeta(string $workDir, array &$resources): bool
3725
    {
3726
        $ATT_KEY = \defined('RESOURCE_ATTENDANCE') ? RESOURCE_ATTENDANCE : 'attendance';
3727
        $base    = rtrim($workDir, '/').'/chamilo/attendance';
3728
        if (!is_dir($base)) {
3729
            return false;
3730
        }
3731
3732
        // 1) Discover files via manifest (preferred), fallback to glob
3733
        $files = [];
3734
        $manifestFile = rtrim($workDir, '/').'/chamilo/manifest.json';
3735
        $manifest = @json_decode((string)@file_get_contents($manifestFile), true);
3736
        if (\is_array($manifest['items'] ?? null)) {
3737
            foreach ($manifest['items'] as $it) {
3738
                if (($it['kind'] ?? '') === 'attendance' && !empty($it['path'])) {
3739
                    $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/');
3740
                    if (is_file($path)) {
3741
                        $files[] = $path;
3742
                    }
3743
                }
3744
            }
3745
        }
3746
        if (empty($files)) {
3747
            foreach ((array)@glob($base.'/attendance_*.json') as $f) {
3748
                if (is_file($f)) {
3749
                    $files[] = $f;
3750
                }
3751
            }
3752
        }
3753
        if (empty($files)) {
3754
            return false;
3755
        }
3756
3757
        // Authoritative: clear bag to avoid duplicates
3758
        $resources[$ATT_KEY] = [];
3759
3760
        $imported = 0;
3761
        foreach ($files as $f) {
3762
            $payload = @json_decode((string)@file_get_contents($f), true);
3763
            if (!\is_array($payload)) {
3764
                continue;
3765
            }
3766
3767
            // ---- Map top-level fields (robust against naming variants)
3768
            $iid   = (int)($payload['id'] ?? 0);
3769
            if ($iid <= 0) {
3770
                $iid = (int)($payload['moduleid'] ?? 0);
3771
            }
3772
            if ($iid <= 0 && preg_match('/attendance_(\d+)\.json$/', (string)$f, $m)) {
3773
                $iid = (int)$m[1];
3774
            }
3775
            if ($iid <= 0) {
3776
                $iid = $this->nextId($resources[$ATT_KEY] ?? []);
3777
            }
3778
3779
            $title = (string)($payload['title'] ?? $payload['name'] ?? 'Attendance');
3780
            $desc  = (string)($payload['description'] ?? $payload['intro'] ?? '');
3781
            $active= (int)   ($payload['active'] ?? 1);
3782
            $locked= (int)   ($payload['locked'] ?? 0);
3783
3784
            // Qualify block may be nested or flattened
3785
            $qual   = \is_array($payload['qualify'] ?? null) ? $payload['qualify'] : [];
3786
            $qualTitle = (string)($qual['title'] ?? $payload['attendance_qualify_title'] ?? '');
3787
            $qualMax   = (int)   ($qual['max']   ?? $payload['attendance_qualify_max'] ?? 0);
3788
            $weight    = (float) ($qual['weight']?? $payload['attendance_weight'] ?? 0.0);
3789
3790
            // ---- Normalize calendars
3791
            $calIn  = (array)($payload['calendars'] ?? []);
3792
            $cals   = [];
3793
            foreach ($calIn as $c) {
3794
                if (!\is_array($c) && !\is_object($c)) {
3795
                    continue;
3796
                }
3797
                $c = (array)$c;
3798
                $cid   = (int)($c['id'] ?? $c['iid'] ?? 0);
3799
                $aid   = (int)($c['attendance_id'] ?? $iid);
3800
                $dt    = (string)($c['date_time'] ?? $c['datetime'] ?? '');
3801
                $done  = (bool)  ($c['done_attendance'] ?? false);
3802
                $block = (bool)  ($c['blocked'] ?? false);
3803
                $dur   = $c['duration'] ?? null;
3804
                $dur   = (null !== $dur) ? (int)$dur : null;
3805
3806
                $cals[] = [
3807
                    'id'              => $cid > 0 ? $cid : $this->nextId($cals),
3808
                    'attendance_id'   => $aid,
3809
                    'date_time'       => $dt,
3810
                    'done_attendance' => $done,
3811
                    'blocked'         => $block,
3812
                    'duration'        => $dur,
3813
                ];
3814
            }
3815
3816
            // ---- Wrap as legacy item compatible with builder
3817
            $item = $this->mkLegacyItem(
3818
                $ATT_KEY,
3819
                $iid,
3820
                [
3821
                    'id'                        => $iid,
3822
                    'title'                     => $title,
3823
                    'name'                      => $title,   // keep alias
3824
                    'description'               => $desc,
3825
                    'active'                    => $active,
3826
                    'attendance_qualify_title'  => $qualTitle,
3827
                    'attendance_qualify_max'    => $qualMax,
3828
                    'attendance_weight'         => $weight,
3829
                    'locked'                    => $locked,
3830
                ],
3831
                ['attendance_calendar'] // list fields that will be appended below
3832
            );
3833
3834
            // Attach calendars collection on the wrapper
3835
            $item->attendance_calendar = $cals;
3836
3837
            $resources[$ATT_KEY][$iid] = $item;
3838
            $imported++;
3839
        }
3840
3841
        if ($this->debug) {
3842
            @error_log('MOODLE_IMPORT: Attendance meta imported='.$imported);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). 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

3842
            /** @scrutinizer ignore-unhandled */ @error_log('MOODLE_IMPORT: Attendance meta imported='.$imported);

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...
3843
        }
3844
3845
        return $imported > 0;
3846
    }
3847
3848
    /**
3849
     * Import Gradebook sidecars written by GradebookMetaExport.
3850
     * Authoritative: if present, replaces any pre-filled bag to avoid duplicates.
3851
     *
3852
     * @return bool true if at least one gradebook was imported
3853
     */
3854
    private function tryImportGradebookMeta(string $workDir, array &$resources): bool
3855
    {
3856
        $GB_KEY = \defined('RESOURCE_GRADEBOOK') ? RESOURCE_GRADEBOOK : 'gradebook';
3857
        $base   = rtrim($workDir, '/').'/chamilo/gradebook';
3858
        if (!is_dir($base)) {
3859
            return false;
3860
        }
3861
3862
        // 1) Discover files via manifest (preferred), fallback to glob
3863
        $files = [];
3864
        $manifestFile = rtrim($workDir, '/').'/chamilo/manifest.json';
3865
        $manifest = @json_decode((string)@file_get_contents($manifestFile), true);
3866
        if (\is_array($manifest['items'] ?? null)) {
3867
            foreach ($manifest['items'] as $it) {
3868
                if (($it['kind'] ?? '') === 'gradebook' && !empty($it['path'])) {
3869
                    $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/');
3870
                    if (is_file($path)) {
3871
                        $files[] = $path;
3872
                    }
3873
                }
3874
            }
3875
        }
3876
        if (empty($files)) {
3877
            foreach ((array)@glob($base.'/gradebook_*.json') as $f) {
3878
                if (is_file($f)) {
3879
                    $files[] = $f;
3880
                }
3881
            }
3882
        }
3883
        if (empty($files)) {
3884
            return false;
3885
        }
3886
3887
        // Authoritative: clear bag to avoid duplicates
3888
        $resources[$GB_KEY] = [];
3889
3890
        $imported = 0;
3891
        foreach ($files as $f) {
3892
            $payload = @json_decode((string)@file_get_contents($f), true);
3893
            if (!\is_array($payload)) {
3894
                continue;
3895
            }
3896
3897
            // Categories are already serialized by the builder; pass them through.
3898
            $categories = \is_array($payload['categories'] ?? null) ? $payload['categories'] : [];
3899
3900
            // Determine a stable id (not really used by Moodle, but kept for parity)
3901
            $iid = (int)($payload['id'] ?? 0);
3902
            if ($iid <= 0) { $iid = (int)($payload['moduleid'] ?? 0); }
3903
            if ($iid <= 0 && preg_match('/gradebook_(\d+)\.json$/', (string)$f, $m)) {
3904
                $iid = (int)$m[1];
3905
            }
3906
            if ($iid <= 0) { $iid = 1; }
3907
3908
            // Build a minimal legacy-like object compatible with GradebookMetaExport::findGradebookBackup()
3909
            $gb = (object)[
3910
                // matches what the exporter looks for
3911
                'categories' => $categories,
3912
3913
                // helpful hints
3914
                'id'        => $iid,
3915
                'source_id' => $iid,
3916
                'title'     => (string)($payload['title'] ?? 'Gradebook'),
3917
            ];
3918
3919
            // Store in canonical bag
3920
            $resources[$GB_KEY][$iid] = $gb;
3921
            $imported++;
3922
        }
3923
3924
        if ($this->debug) {
3925
            @error_log('MOODLE_IMPORT: Gradebook meta imported='.$imported);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). 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

3925
            /** @scrutinizer ignore-unhandled */ @error_log('MOODLE_IMPORT: Gradebook meta imported='.$imported);

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...
3926
        }
3927
3928
        return $imported > 0;
3929
    }
3930
3931
    /**
3932
     * Read activities/wiki_{moduleId}/wiki.xml and return:
3933
     *  - meta: module-level info (name, moduleid, sectionid)
3934
     *  - pages: array of pages with id,title,content,contentformat,version,userid,timecreated,timemodified
3935
     */
3936
    private function readWikiModuleFull(string $xmlPath): array
3937
    {
3938
        $doc = $this->loadXml($xmlPath);
3939
        $xp  = new DOMXPath($doc);
3940
3941
        // Module meta
3942
        $activity = $xp->query('/activity')->item(0);
3943
        $moduleId = (int) ($activity?->getAttribute('moduleid') ?? 0);
3944
3945
        $nameNode = $xp->query('//wiki/name')->item(0);
3946
        $name = (string) ($nameNode?->nodeValue ?? 'Wiki');
3947
3948
        // Some exports put sectionid on <activity>; default 0
3949
        $sectionId = (int) ($xp->query('/activity')->item(0)?->getAttribute('contextid') ?? 0);
3950
3951
        $pages = [];
3952
        foreach ($xp->query('//wiki/subwikis/subwiki/pages/page') as $node) {
3953
            /** @var DOMElement $node */
3954
            $pid   = (int) ($node->getAttribute('id') ?: 0);
3955
            $title = (string) ($node->getElementsByTagName('title')->item(0)?->nodeValue ?? ('Wiki page '.$pid));
3956
            $uid   = (int)    ($node->getElementsByTagName('userid')->item(0)?->nodeValue ?? 0);
3957
3958
            $timeCreated  = (int) ($node->getElementsByTagName('timecreated')->item(0)?->nodeValue ?? time());
3959
            $timeModified = (int) ($node->getElementsByTagName('timemodified')->item(0)?->nodeValue ?? $timeCreated);
3960
3961
            // Prefer cachedcontent; fallback to the last <versions>/<version>/content
3962
            $cached = $node->getElementsByTagName('cachedcontent')->item(0)?->nodeValue ?? '';
3963
            $content = (string) $cached;
3964
            $version = 1;
3965
3966
            $versionsEl = $node->getElementsByTagName('versions')->item(0);
3967
            if ($versionsEl instanceof DOMElement) {
3968
                $versNodes = $versionsEl->getElementsByTagName('version');
3969
                if ($versNodes->length > 0) {
3970
                    $last = $versNodes->item($versNodes->length - 1);
3971
                    $vHtml = $last?->getElementsByTagName('content')->item(0)?->nodeValue ?? '';
3972
                    $vNum  = (int) ($last?->getElementsByTagName('version')->item(0)?->nodeValue ?? 1);
3973
                    if (trim((string)$vHtml) !== '') {
3974
                        $content = (string) $vHtml;
3975
                    }
3976
                    if ($vNum > 0) {
3977
                        $version = $vNum;
3978
                    }
3979
                }
3980
            }
3981
3982
            $pages[] = [
3983
                'id'            => $pid,
3984
                'title'         => $title,
3985
                'content'       => $content,
3986
                'contentformat' => 'html',
3987
                'version'       => $version,
3988
                'timecreated'   => $timeCreated,
3989
                'timemodified'  => $timeModified,
3990
                'userid'        => $uid,
3991
                'reflink'       => $this->slugify($title),
3992
            ];
3993
        }
3994
3995
        // Stable order
3996
        usort($pages, fn(array $a, array $b) => $a['id'] <=> $b['id']);
3997
3998
        return [
3999
            [
4000
                'moduleid'   => $moduleId,
4001
                'sectionid'  => $sectionId,
4002
                'name'       => $name,
4003
            ],
4004
            $pages,
4005
        ];
4006
    }
4007
4008
    private function rewritePluginfileBasic(string $html, string $context): string
4009
    {
4010
        if ($html === '' || !str_contains($html, '@@PLUGINFILE@@')) {
4011
            return $html;
4012
        }
4013
4014
        // src/href/poster/data
4015
        $html = (string)preg_replace(
4016
            '~\b(src|href|poster|data)\s*=\s*([\'"])@@PLUGINFILE@@/([^\'"]+)\2~i',
4017
            '$1=$2/document/moodle_pages/$3$2',
4018
            $html
4019
        );
4020
4021
        // url(...) in inline styles
4022
        $html = (string)preg_replace(
4023
            '~url\((["\']?)@@PLUGINFILE@@/([^)\'"]+)\1\)~i',
4024
            'url($1/document/moodle_pages/$2$1)',
4025
            $html
4026
        );
4027
4028
        return $html;
4029
    }
4030
4031
    /** Check if chamilo manifest has any 'announcement' kind to avoid duplicates */
4032
    private function hasChamiloAnnouncementMeta(string $exportRoot): bool
4033
    {
4034
        $mf = rtrim($exportRoot, '/').'/chamilo/manifest.json';
4035
        if (!is_file($mf)) { return false; }
4036
        $data = json_decode((string)file_get_contents($mf), true);
4037
        if (!is_array($data) || empty($data['items'])) { return false; }
4038
        foreach ((array)$data['items'] as $it) {
4039
            $k = strtolower((string)($it['kind'] ?? ''));
4040
            if ($k === 'announcement' || $k === 'announcement') {
4041
                return true;
4042
            }
4043
        }
4044
        return false;
4045
    }
4046
4047
    /** Read minimal forum header (type, name) */
4048
    private function readForumHeader(string $moduleXml): array
4049
    {
4050
        $doc = $this->loadXml($moduleXml);
4051
        $xp  = new \DOMXPath($doc);
4052
        $type = (string) ($xp->query('//forum/type')->item(0)?->nodeValue ?? '');
4053
        $name = (string) ($xp->query('//forum/name')->item(0)?->nodeValue ?? '');
4054
        return ['type' => $type, 'name' => $name];
4055
    }
4056
4057
    /**
4058
     * Parse forum.xml (news) → array of announcements:
4059
     * [
4060
     *   [
4061
     *     'title' => string,
4062
     *     'html'  => string,
4063
     *     'date'  => 'Y-m-d H:i:s',
4064
     *     'attachments' => [ {path, filename, size, comment, asset_relpath}... ],
4065
     *     'first_path'  => string,
4066
     *     'first_name'  => string,
4067
     *     'first_size'  => int,
4068
     *   ], ...
4069
     * ]
4070
     */
4071
    private function readAnnouncementsFromForum(string $moduleXml, string $exportRoot): array
4072
    {
4073
        $doc = $this->loadXml($moduleXml);
4074
        $xp  = new \DOMXPath($doc);
4075
4076
        $anns = [];
4077
        // One discussion = one announcement; firstpost = main message
4078
        foreach ($xp->query('//forum/discussions/discussion') as $d) {
4079
            /** @var \DOMElement $d */
4080
            $title = (string) ($d->getElementsByTagName('name')->item(0)?->nodeValue ?? 'Announcement');
4081
            $firstPostId = (int) ($d->getElementsByTagName('firstpost')->item(0)?->nodeValue ?? 0);
4082
            $created = (int) ($d->getElementsByTagName('timemodified')->item(0)?->nodeValue ?? time());
4083
4084
            // find post by id
4085
            $postNode = null;
4086
            foreach ($d->getElementsByTagName('post') as $p) {
4087
                /** @var \DOMElement $p */
4088
                $pid = (int) $p->getAttribute('id');
4089
                if ($pid === $firstPostId || ($firstPostId === 0 && !$postNode)) {
4090
                    $postNode = $p;
4091
                    if ($pid === $firstPostId) { break; }
4092
                }
4093
            }
4094
            if (!$postNode) { continue; }
4095
4096
            $subject = (string) ($postNode->getElementsByTagName('subject')->item(0)?->nodeValue ?? $title);
4097
            $message = (string) ($postNode->getElementsByTagName('message')->item(0)?->nodeValue ?? '');
4098
            $createdPost = (int) ($postNode->getElementsByTagName('created')->item(0)?->nodeValue ?? $created);
4099
4100
            // Normalize HTML and rewrite @@PLUGINFILE@@
4101
            $html = $this->rewritePluginfileForAnnouncements($message, $exportRoot, (int)$postNode->getAttribute('id'));
4102
4103
            // Attachments from files.xml (component=mod_forum, filearea=post, itemid=postId)
4104
            $postId = (int) $postNode->getAttribute('id');
4105
            $attachments = $this->extractForumPostAttachments($exportRoot, $postId);
4106
4107
            // First attachment info (builder-style)
4108
            $first = $attachments[0] ?? null;
4109
            $anns[] = [
4110
                'title'       => $subject !== '' ? $subject : $title,
4111
                'html'        => $html,
4112
                'date'        => date('Y-m-d H:i:s', $createdPost ?: $created),
4113
                'attachments' => $attachments,
4114
                'first_path'  => (string) ($first['path'] ?? ''),
4115
                'first_name'  => (string) ($first['filename'] ?? ''),
4116
                'first_size'  => (int)    ($first['size'] ?? 0),
4117
            ];
4118
        }
4119
4120
        return $anns;
4121
    }
4122
4123
    /**
4124
     * Rewrite @@PLUGINFILE@@ URLs in forum messages to point to /document/announcements/{postId}/<file>.
4125
     * The physical copy is handled by extractForumPostAttachments().
4126
     */
4127
    private function rewritePluginfileForAnnouncements(string $html, string $exportRoot, int $postId): string
4128
    {
4129
        if ($html === '' || !str_contains($html, '@@PLUGINFILE@@')) { return $html; }
4130
4131
        $targetBase = '/document/announcements/'.$postId.'/';
4132
4133
        // src/href/poster/data
4134
        $html = (string)preg_replace(
4135
            '~\b(src|href|poster|data)\s*=\s*([\'"])@@PLUGINFILE@@/([^\'"]+)\2~i',
4136
            '$1=$2'.$targetBase.'$3$2',
4137
            $html
4138
        );
4139
4140
        // url(...) in inline styles
4141
        $html = (string)preg_replace(
4142
            '~url\((["\']?)@@PLUGINFILE@@/([^)\'"]+)\1\)~i',
4143
            'url($1'.$targetBase.'$2$1)',
4144
            $html
4145
        );
4146
4147
        return $html;
4148
    }
4149
4150
    /**
4151
     * Copy attachments for a forum post from files.xml store to /document/announcements/{postId}/
4152
     * and return normalized descriptors for the announcement payload.
4153
     */
4154
    private function extractForumPostAttachments(string $exportRoot, int $postId): array
4155
    {
4156
        $fx = rtrim($exportRoot, '/').'/files.xml';
4157
        if (!is_file($fx)) { return []; }
4158
4159
        $doc = $this->loadXml($fx);
4160
        $xp  = new \DOMXPath($doc);
4161
4162
        // files/file with these conditions
4163
        $q = sprintf("//files/file[component='mod_forum' and filearea='post' and itemid='%d']", $postId);
4164
        $list = $xp->query($q);
4165
4166
        if ($list->length === 0) { return []; }
4167
4168
        $destBase = rtrim($exportRoot, '/').'/document/announcements/'.$postId;
4169
        $this->ensureDir($destBase);
4170
4171
        $out = [];
4172
        foreach ($list as $f) {
4173
            /** @var \DOMElement $f */
4174
            $filename = (string) ($f->getElementsByTagName('filename')->item(0)?->nodeValue ?? '');
4175
            if ($filename === '' || $filename === '.') { continue; } // skip directories
4176
4177
            $contenthash = (string) ($f->getElementsByTagName('contenthash')->item(0)?->nodeValue ?? '');
4178
            $filesize    = (int)    ($f->getElementsByTagName('filesize')->item(0)?->nodeValue ?? 0);
4179
4180
            // Moodle file path inside backup: files/aa/bb/<contenthash>
4181
            $src = rtrim($exportRoot, '/').'/files/'.substr($contenthash, 0, 2).'/'.substr($contenthash, 2, 2).'/'.$contenthash;
4182
            $dst = $destBase.'/'.$filename;
4183
4184
            if (is_file($src)) {
4185
                @copy($src, $dst);
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

4185
                /** @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...
4186
            } else {
4187
                // keep record even if missing; size may still be useful
4188
                if ($this->debug) { error_log("MOODLE_IMPORT: forum post attachment missing file=$src"); }
4189
            }
4190
4191
            $rel = 'document/announcements/'.$postId.'/'.$filename; // relative inside backup root
4192
            $out[] = [
4193
                'path'           => $rel,            // builder sets 'attachment_path' from first item
4194
                'filename'       => $filename,
4195
                'size'           => $filesize,
4196
                'comment'        => '',
4197
                'asset_relpath'  => $rel,            // mirrors builder's asset_relpath semantics
4198
            ];
4199
        }
4200
4201
        return $out;
4202
    }
4203
4204
    private function isNewsForum(array $forumInfo): bool
4205
    {
4206
        $type  = strtolower((string)($forumInfo['type'] ?? ''));
4207
        if ($type === 'news') {
4208
            return true;
4209
        }
4210
        $name  = strtolower((string)($forumInfo['name'] ?? ''));
4211
        $intro = strtolower((string)($forumInfo['description'] ?? $forumInfo['intro'] ?? ''));
4212
4213
        // Common names across locales
4214
        $nameHints = ['announcement'];
4215
        foreach ($nameHints as $h) {
4216
            if ($name !== '' && str_contains($name, $h)) {
4217
                return true;
4218
            }
4219
        }
4220
4221
        return false;
4222
    }
4223
4224
    /**
4225
     * Detect the special "course homepage" Page exported as activities/page_0.
4226
     * Heuristics:
4227
     *  - directory ends with 'activities/page_0'
4228
     *  - or <activity id="0" moduleid="0" modulename="page">
4229
     *  - or page name equals 'Introduction' (soft signal)
4230
     */
4231
    private function looksLikeCourseHomepage(string $dir, string $moduleXml): bool
4232
    {
4233
        if (preg_match('~/activities/page_0/?$~', $dir)) {
4234
            return true;
4235
        }
4236
4237
        try {
4238
            $doc = $this->loadXml($moduleXml);
4239
            $xp  = new DOMXPath($doc);
4240
4241
            $idAttr       = $xp->query('/activity/@id')->item(0)?->nodeValue ?? null;
4242
            $moduleIdAttr = $xp->query('/activity/@moduleid')->item(0)?->nodeValue ?? null;
4243
            $modNameAttr  = $xp->query('/activity/@modulename')->item(0)?->nodeValue ?? null;
4244
            $nameNode     = $xp->query('//page/name')->item(0)?->nodeValue ?? '';
4245
4246
            $id       = is_numeric($idAttr)       ? (int) $idAttr       : null;
4247
            $moduleId = is_numeric($moduleIdAttr) ? (int) $moduleIdAttr : null;
4248
            $modName  = is_string($modNameAttr)   ? strtolower($modNameAttr) : '';
4249
4250
            if ($id === 0 && $moduleId === 0 && $modName === 'page') {
4251
                return true;
4252
            }
4253
            if (trim($nameNode) === 'Introduction') {
4254
                // Soft hint: do not exclusively rely on this, but keep as fallback
4255
                return true;
4256
            }
4257
        } catch (\Throwable $e) {
4258
            // Be tolerant: if parsing fails, just return false
4259
        }
4260
4261
        return false;
4262
    }
4263
4264
    /**
4265
     * Read <page><content>...</content></page> as decoded HTML.
4266
     */
4267
    private function readPageContent(string $moduleXml): string
4268
    {
4269
        $doc = $this->loadXml($moduleXml);
4270
        $xp  = new DOMXPath($doc);
4271
4272
        $node = $xp->query('//page/content')->item(0);
4273
        if (!$node) {
4274
            return '';
4275
        }
4276
4277
        // PageExport wrote content with htmlspecialchars; decode back to HTML.
4278
        return html_entity_decode($node->nodeValue ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
4279
    }
4280
4281
}
4282