Passed
Push — master ( 68f627...939207 )
by
unknown
12:17 queued 03:34
created

MoodleExport   F

Complexity

Total Complexity 136

Size/Duplication

Total Lines 868
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 540
c 1
b 0
f 0
dl 0
loc 868
rs 2
wmc 136

25 Methods

Rating   Name   Duplication   Size   Complexity  
B getSections() 0 39 11
A exportSections() 0 6 2
A export() 0 50 5
A createMbzFile() 0 30 6
B fillQuestionsFromQuiz() 0 12 7
A exportBackupSettings() 0 57 3
A exportScalesXml() 0 6 1
A exportOutcomesXml() 0 6 1
A exportGradebookXml() 0 6 1
A exportGroupsXml() 0 6 1
A setAdminUserData() 0 49 1
A cleanupTempDir() 0 3 1
A exportCompletionXml() 0 6 1
F createMoodleBackupXml() 0 130 19
A getAdminUserData() 0 3 1
B exportUsersXml() 0 66 4
A exportRootXmlFiles() 0 23 3
A exportBadgesXml() 0 6 1
A exportQuestionsXml() 0 37 4
A exportGradeHistoryXml() 0 6 1
D getActivities() 0 139 40
A exportRolesXml() 0 15 1
C fillResourcesFromLearnpath() 0 35 16
A __construct() 0 17 2
A recursiveDelete() 0 8 3

How to fix   Complexity   

Complex Class

Complex classes like MoodleExport often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MoodleExport, and based on these observations, apply Extract Interface, too.

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\CourseBundle\Component\CourseCopy\CourseBuilder;
10
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\AssignExport;
11
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\FeedbackExport;
12
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ForumExport;
13
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\GlossaryExport;
14
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\PageExport;
15
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\QuizExport;
16
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ResourceExport;
17
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\UrlExport;
18
use Exception;
19
use RecursiveDirectoryIterator;
20
use RecursiveIteratorIterator;
21
use ZipArchive;
22
23
use const PATHINFO_EXTENSION;
24
use const PHP_EOL;
25
26
/**
27
 * Class MoodleExport.
28
 * Handles the export of a Moodle course in .mbz format.
29
 */
30
class MoodleExport
31
{
32
    /**
33
     * @var object
34
     */
35
    private $course;
36
37
    /**
38
     * @var array<string,mixed>
39
     */
40
    private static $adminUserData = [];
41
42
    /**
43
     * @var bool selection flag (true when exporting only selected items)
44
     */
45
    private bool $selectionMode = false;
46
47
    /**
48
     * Constructor to initialize the course object.
49
     *
50
     * @param object $course        Filtered legacy course (may be full or selected-only)
51
     * @param bool   $selectionMode When true, do NOT re-hydrate from complete snapshot
52
     */
53
    public function __construct(object $course, bool $selectionMode = false)
54
    {
55
        // Keep the provided (possibly filtered) course as-is.
56
        $this->course = $course;
57
        $this->selectionMode = $selectionMode;
58
59
        // Only auto-fill missing dependencies when doing a full export.
60
        // In selection mode we must not re-inject extra content ("full backup" effect).
61
        if (!$this->selectionMode) {
62
            $cb = new CourseBuilder('complete');
63
            $complete = $cb->build(0, (string) ($course->code ?? ''));
64
65
            // Fill missing resources from learnpath (full export only)
66
            $this->fillResourcesFromLearnpath($complete);
67
68
            // Fill missing quiz questions (full export only)
69
            $this->fillQuestionsFromQuiz($complete);
70
        }
71
    }
72
73
    /**
74
     * Export the Moodle course in .mbz format.
75
     *
76
     * @return string Path to the created .mbz file
77
     */
78
    public function export(string $courseId, string $exportDir, int $version)
79
    {
80
        $tempDir = api_get_path(SYS_ARCHIVE_PATH).$exportDir;
81
82
        if (!is_dir($tempDir)) {
83
            if (!mkdir($tempDir, api_get_permissions_for_new_directories(), true)) {
84
                throw new Exception(get_lang('ErrorCreatingDirectory'));
85
            }
86
        }
87
88
        $courseInfo = api_get_course_info($courseId);
89
        if (!$courseInfo) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $courseInfo 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...
90
            throw new Exception(get_lang('CourseNotFound'));
91
        }
92
93
        // Generate the moodle_backup.xml
94
        $this->createMoodleBackupXml($tempDir, $version);
95
96
        // Get the activities from the course
97
        $activities = $this->getActivities();
98
99
        // Export course-related files
100
        $courseExport = new CourseExport($this->course, $activities);
101
        $courseExport->exportCourse($tempDir);
102
103
        // Export files-related data and actual files
104
        $pageExport = new PageExport($this->course);
105
        $pageFiles = [];
106
        $pageData = $pageExport->getData(0, 1);
107
        if (!empty($pageData['files'])) {
108
            $pageFiles = $pageData['files'];
109
        }
110
        $fileExport = new FileExport($this->course);
111
        $filesData = $fileExport->getFilesData();
112
        $filesData['files'] = array_merge($filesData['files'], $pageFiles);
113
        $fileExport->exportFiles($filesData, $tempDir);
114
115
        // Export sections of the course
116
        $this->exportSections($tempDir);
117
118
        // Export all root XML files
119
        $this->exportRootXmlFiles($tempDir);
120
121
        // Compress everything into a .mbz (ZIP) file
122
        $exportedFile = $this->createMbzFile($tempDir);
123
124
        // Clean up temporary directory
125
        $this->cleanupTempDir($tempDir);
126
127
        return $exportedFile;
128
    }
129
130
    /**
131
     * Export questions data to XML file.
132
     */
133
    public function exportQuestionsXml(array $questionsData, string $exportDir): void
134
    {
135
        $quizExport = new QuizExport($this->course);
136
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
137
        $xmlContent .= '<question_categories>'.PHP_EOL;
138
139
        $categoryHashes = [];
140
        foreach ($questionsData as $quiz) {
141
            $categoryId = $quiz['questions'][0]['questioncategoryid'] ?? '1';
142
            $hash = md5($categoryId.($quiz['name'] ?? ''));
143
            if (isset($categoryHashes[$hash])) {
144
                continue;
145
            }
146
            $categoryHashes[$hash] = true;
147
            $xmlContent .= '  <question_category id="'.$categoryId.'">'.PHP_EOL;
148
            $xmlContent .= '    <name>Default for '.htmlspecialchars((string) $quiz['name'] ?? 'Unknown').'</name>'.PHP_EOL;
149
            $xmlContent .= '    <contextid>'.($quiz['contextid'] ?? '0').'</contextid>'.PHP_EOL;
150
            $xmlContent .= '    <contextlevel>70</contextlevel>'.PHP_EOL;
151
            $xmlContent .= '    <contextinstanceid>'.($quiz['moduleid'] ?? '0').'</contextinstanceid>'.PHP_EOL;
152
            $xmlContent .= '    <info>The default category for questions shared in context "'.htmlspecialchars($quiz['name'] ?? 'Unknown').'".</info>'.PHP_EOL;
153
            $xmlContent .= '    <infoformat>0</infoformat>'.PHP_EOL;
154
            $xmlContent .= '    <stamp>moodle+'.time().'+CATEGORYSTAMP</stamp>'.PHP_EOL;
155
            $xmlContent .= '    <parent>0</parent>'.PHP_EOL;
156
            $xmlContent .= '    <sortorder>999</sortorder>'.PHP_EOL;
157
            $xmlContent .= '    <idnumber>$@NULL@$</idnumber>'.PHP_EOL;
158
            $xmlContent .= '    <questions>'.PHP_EOL;
159
160
            foreach ($quiz['questions'] as $question) {
161
                $xmlContent .= $quizExport->exportQuestion($question);
162
            }
163
164
            $xmlContent .= '    </questions>'.PHP_EOL;
165
            $xmlContent .= '  </question_category>'.PHP_EOL;
166
        }
167
168
        $xmlContent .= '</question_categories>';
169
        file_put_contents($exportDir.'/questions.xml', $xmlContent);
170
    }
171
172
    /**
173
     * Sets the admin user data.
174
     */
175
    public function setAdminUserData(int $id, string $username, string $email): void
176
    {
177
        self::$adminUserData = [
178
            'id' => $id,
179
            'contextid' => $id,
180
            'username' => $username,
181
            'idnumber' => '',
182
            'email' => $email,
183
            'phone1' => '',
184
            'phone2' => '',
185
            'institution' => '',
186
            'department' => '',
187
            'address' => '',
188
            'city' => 'London',
189
            'country' => 'GB',
190
            'lastip' => '127.0.0.1',
191
            'picture' => '0',
192
            'description' => '',
193
            'descriptionformat' => 1,
194
            'imagealt' => '$@NULL@$',
195
            'auth' => 'manual',
196
            'firstname' => 'Admin',
197
            'lastname' => 'User',
198
            'confirmed' => 1,
199
            'policyagreed' => 0,
200
            'deleted' => 0,
201
            'lang' => 'en',
202
            'theme' => '',
203
            'timezone' => 99,
204
            'firstaccess' => time(),
205
            'lastaccess' => time() - (60 * 60 * 24 * 7),
206
            'lastlogin' => time() - (60 * 60 * 24 * 2),
207
            'currentlogin' => time(),
208
            'mailformat' => 1,
209
            'maildigest' => 0,
210
            'maildisplay' => 1,
211
            'autosubscribe' => 1,
212
            'trackforums' => 0,
213
            'timecreated' => time(),
214
            'timemodified' => time(),
215
            'trustbitmask' => 0,
216
            'preferences' => [
217
                ['name' => 'core_message_migrate_data', 'value' => 1],
218
                ['name' => 'auth_manual_passwordupdatetime', 'value' => time()],
219
                ['name' => 'email_bounce_count', 'value' => 1],
220
                ['name' => 'email_send_count', 'value' => 1],
221
                ['name' => 'login_failed_count_since_success', 'value' => 0],
222
                ['name' => 'filepicker_recentrepository', 'value' => 5],
223
                ['name' => 'filepicker_recentlicense', 'value' => 'unknown'],
224
            ],
225
        ];
226
    }
227
228
    /**
229
     * Returns hardcoded data for the admin user.
230
     *
231
     * @return array<string,mixed>
232
     */
233
    public static function getAdminUserData(): array
234
    {
235
        return self::$adminUserData;
236
    }
237
238
    /**
239
     * Pulls dependent resources that LP items reference (only when LP bag exists).
240
     * Defensive: if no learnpath bag is present (e.g., exporting only documents),
241
     * this becomes a no-op. Keeps current behavior untouched when LP exist.
242
     */
243
    private function fillResourcesFromLearnpath(object $complete): void
244
    {
245
        // Accept both constant and plain-string keys defensively.
246
        $lpBag =
247
            $this->course->resources[\defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath']
248
            ?? $this->course->resources['learnpath']
249
            ?? [];
250
251
        if (empty($lpBag) || !\is_array($lpBag)) {
252
            // No learnpaths selected/present → nothing to hydrate.
253
            return;
254
        }
255
256
        foreach ($lpBag as $learnpathId => $learnpath) {
257
            // $learnpath may be wrapped in ->obj
258
            $lp = (\is_object($learnpath) && isset($learnpath->obj) && \is_object($learnpath->obj))
259
                ? $learnpath->obj
260
                : $learnpath;
261
262
            if (!\is_object($lp) || empty($lp->items) || !\is_array($lp->items)) {
263
                continue;
264
            }
265
266
            foreach ($lp->items as $item) {
267
                // Legacy LP items expose "item_type" and "path" (resource id)
268
                $type = $item['item_type'] ?? null;
269
                $resourceId = $item['path'] ?? null;
270
                if (!$type || null === $resourceId) {
271
                    continue;
272
                }
273
274
                // Bring missing deps from the complete snapshot (keeps old behavior when LP exist)
275
                if (isset($complete->resources[$type][$resourceId])
276
                    && !isset($this->course->resources[$type][$resourceId])) {
277
                    $this->course->resources[$type][$resourceId] = $complete->resources[$type][$resourceId];
278
                }
279
            }
280
        }
281
    }
282
283
    private function fillQuestionsFromQuiz(object $complete): void
284
    {
285
        if (!isset($this->course->resources['quiz'])) {
286
            return;
287
        }
288
        foreach ($this->course->resources['quiz'] as $quizId => $quiz) {
289
            if (!isset($quiz->obj->question_ids)) {
290
                continue;
291
            }
292
            foreach ($quiz->obj->question_ids as $questionId) {
293
                if (isset($complete->resources['Exercise_Question'][$questionId]) && !isset($this->course->resources['Exercise_Question'][$questionId])) {
294
                    $this->course->resources['Exercise_Question'][$questionId] = $complete->resources['Exercise_Question'][$questionId];
295
                }
296
            }
297
        }
298
    }
299
300
    private function exportRootXmlFiles(string $exportDir): void
301
    {
302
        $this->exportBadgesXml($exportDir);
303
        $this->exportCompletionXml($exportDir);
304
        $this->exportGradebookXml($exportDir);
305
        $this->exportGradeHistoryXml($exportDir);
306
        $this->exportGroupsXml($exportDir);
307
        $this->exportOutcomesXml($exportDir);
308
309
        $activities = $this->getActivities();
310
        $questionsData = [];
311
        foreach ($activities as $activity) {
312
            if ('quiz' === $activity['modulename']) {
313
                $quizExport = new QuizExport($this->course);
314
                $quizData = $quizExport->getData($activity['id'], $activity['sectionid']);
315
                $questionsData[] = $quizData;
316
            }
317
        }
318
        $this->exportQuestionsXml($questionsData, $exportDir);
319
320
        $this->exportRolesXml($exportDir);
321
        $this->exportScalesXml($exportDir);
322
        $this->exportUsersXml($exportDir);
323
    }
324
325
    private function createMoodleBackupXml(string $destinationDir, int $version): void
326
    {
327
        $courseInfo = api_get_course_info($this->course->code);
328
        $backupId = md5(bin2hex(random_bytes(16)));
329
        $siteHash = md5(bin2hex(random_bytes(16)));
330
        $wwwRoot = api_get_path(WEB_PATH);
331
332
        $courseStartDate = strtotime($courseInfo['creation_date']);
333
        $courseEndDate = $courseStartDate + (365 * 24 * 60 * 60);
334
335
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
336
        $xmlContent .= '<moodle_backup>'.PHP_EOL;
337
        $xmlContent .= '  <information>'.PHP_EOL;
338
339
        $xmlContent .= '    <name>backup-'.htmlspecialchars((string) $courseInfo['code']).'.mbz</name>'.PHP_EOL;
340
        $xmlContent .= '    <moodle_version>'.(3 === $version ? '2021051718' : '2022041900').'</moodle_version>'.PHP_EOL;
341
        $xmlContent .= '    <moodle_release>'.(3 === $version ? '3.11.18 (Build: 20231211)' : '4.x version here').'</moodle_release>'.PHP_EOL;
342
        $xmlContent .= '    <backup_version>'.(3 === $version ? '2021051700' : '2022041900').'</backup_version>'.PHP_EOL;
343
        $xmlContent .= '    <backup_release>'.(3 === $version ? '3.11' : '4.x').'</backup_release>'.PHP_EOL;
344
        $xmlContent .= '    <backup_date>'.time().'</backup_date>'.PHP_EOL;
345
        $xmlContent .= '    <mnet_remoteusers>0</mnet_remoteusers>'.PHP_EOL;
346
        $xmlContent .= '    <include_files>1</include_files>'.PHP_EOL;
347
        $xmlContent .= '    <include_file_references_to_external_content>0</include_file_references_to_external_content>'.PHP_EOL;
348
        $xmlContent .= '    <original_wwwroot>'.$wwwRoot.'</original_wwwroot>'.PHP_EOL;
349
        $xmlContent .= '    <original_site_identifier_hash>'.$siteHash.'</original_site_identifier_hash>'.PHP_EOL;
350
        $xmlContent .= '    <original_course_id>'.htmlspecialchars((string) $courseInfo['real_id']).'</original_course_id>'.PHP_EOL;
351
        $xmlContent .= '    <original_course_format>'.get_lang('Topics').'</original_course_format>'.PHP_EOL;
352
        $xmlContent .= '    <original_course_fullname>'.htmlspecialchars((string) $courseInfo['title']).'</original_course_fullname>'.PHP_EOL;
353
        $xmlContent .= '    <original_course_shortname>'.htmlspecialchars((string) $courseInfo['code']).'</original_course_shortname>'.PHP_EOL;
354
        $xmlContent .= '    <original_course_startdate>'.$courseStartDate.'</original_course_startdate>'.PHP_EOL;
355
        $xmlContent .= '    <original_course_enddate>'.$courseEndDate.'</original_course_enddate>'.PHP_EOL;
356
        $xmlContent .= '    <original_course_contextid>'.$courseInfo['real_id'].'</original_course_contextid>'.PHP_EOL;
357
        $xmlContent .= '    <original_system_contextid>'.api_get_current_access_url_id().'</original_system_contextid>'.PHP_EOL;
358
359
        $xmlContent .= '    <details>'.PHP_EOL;
360
        $xmlContent .= '      <detail backup_id="'.$backupId.'">'.PHP_EOL;
361
        $xmlContent .= '        <type>course</type>'.PHP_EOL;
362
        $xmlContent .= '        <format>moodle2</format>'.PHP_EOL;
363
        $xmlContent .= '        <interactive>1</interactive>'.PHP_EOL;
364
        $xmlContent .= '        <mode>10</mode>'.PHP_EOL;
365
        $xmlContent .= '        <execution>1</execution>'.PHP_EOL;
366
        $xmlContent .= '        <executiontime>0</executiontime>'.PHP_EOL;
367
        $xmlContent .= '      </detail>'.PHP_EOL;
368
        $xmlContent .= '    </details>'.PHP_EOL;
369
370
        $xmlContent .= '    <contents>'.PHP_EOL;
371
372
        $sections = $this->getSections();
373
        if (!empty($sections)) {
374
            $xmlContent .= '      <sections>'.PHP_EOL;
375
            foreach ($sections as $section) {
376
                $xmlContent .= '        <section>'.PHP_EOL;
377
                $xmlContent .= '          <sectionid>'.$section['id'].'</sectionid>'.PHP_EOL;
378
                $xmlContent .= '          <title>'.htmlspecialchars((string) $section['name']).'</title>'.PHP_EOL;
379
                $xmlContent .= '          <directory>sections/section_'.$section['id'].'</directory>'.PHP_EOL;
380
                $xmlContent .= '        </section>'.PHP_EOL;
381
            }
382
            $xmlContent .= '      </sections>'.PHP_EOL;
383
        }
384
385
        $seenActs = [];
386
        $activitiesFlat = [];
387
        foreach ($sections as $section) {
388
            foreach ($section['activities'] as $a) {
389
                $modname = (string) ($a['modulename'] ?? '');
390
                $moduleid = isset($a['moduleid']) ? (int) $a['moduleid'] : null;
391
                if ('' === $modname || null === $moduleid || $moduleid < 0) {
392
                    continue;
393
                }
394
                $key = $modname.':'.$moduleid;
395
                if (isset($seenActs[$key])) {
396
                    continue;
397
                }
398
                $seenActs[$key] = true;
399
400
                $title = (string) ($a['title'] ?? $a['name'] ?? '');
401
                $activitiesFlat[] = [
402
                    'moduleid' => $moduleid,
403
                    'sectionid' => (int) $section['id'],
404
                    'modulename' => $modname,
405
                    'title' => $title,
406
                ];
407
            }
408
        }
409
410
        if (!empty($activitiesFlat)) {
411
            $xmlContent .= '      <activities>'.PHP_EOL;
412
            foreach ($activitiesFlat as $activity) {
413
                $xmlContent .= '        <activity>'.PHP_EOL;
414
                $xmlContent .= '          <moduleid>'.$activity['moduleid'].'</moduleid>'.PHP_EOL;
415
                $xmlContent .= '          <sectionid>'.$activity['sectionid'].'</sectionid>'.PHP_EOL;
416
                $xmlContent .= '          <modulename>'.htmlspecialchars((string) $activity['modulename']).'</modulename>'.PHP_EOL;
417
                $xmlContent .= '          <title>'.htmlspecialchars((string) $activity['title']).'</title>'.PHP_EOL;
418
                $xmlContent .= '          <directory>activities/'.$activity['modulename'].'_'.$activity['moduleid'].'</directory>'.PHP_EOL;
419
                $xmlContent .= '        </activity>'.PHP_EOL;
420
            }
421
            $xmlContent .= '      </activities>'.PHP_EOL;
422
        }
423
424
        $xmlContent .= '      <course>'.PHP_EOL;
425
        $xmlContent .= '        <courseid>'.$courseInfo['real_id'].'</courseid>'.PHP_EOL;
426
        $xmlContent .= '        <title>'.htmlspecialchars((string) $courseInfo['title']).'</title>'.PHP_EOL;
427
        $xmlContent .= '        <directory>course</directory>'.PHP_EOL;
428
        $xmlContent .= '      </course>'.PHP_EOL;
429
430
        $xmlContent .= '    </contents>'.PHP_EOL;
431
432
        $xmlContent .= '    <settings>'.PHP_EOL;
433
        $activities = $activitiesFlat;
434
        $settings = $this->exportBackupSettings($sections, $activities);
435
        foreach ($settings as $setting) {
436
            $xmlContent .= '      <setting>'.PHP_EOL;
437
            $xmlContent .= '        <level>'.htmlspecialchars($setting['level']).'</level>'.PHP_EOL;
438
            $xmlContent .= '        <name>'.htmlspecialchars($setting['name']).'</name>'.PHP_EOL;
439
            $xmlContent .= '        <value>'.$setting['value'].'</value>'.PHP_EOL;
440
            if (isset($setting['section'])) {
441
                $xmlContent .= '        <section>'.htmlspecialchars($setting['section']).'</section>'.PHP_EOL;
442
            }
443
            if (isset($setting['activity'])) {
444
                $xmlContent .= '        <activity>'.htmlspecialchars($setting['activity']).'</activity>'.PHP_EOL;
445
            }
446
            $xmlContent .= '      </setting>'.PHP_EOL;
447
        }
448
        $xmlContent .= '    </settings>'.PHP_EOL;
449
450
        $xmlContent .= '  </information>'.PHP_EOL;
451
        $xmlContent .= '</moodle_backup>';
452
453
        $xmlFile = $destinationDir.'/moodle_backup.xml';
454
        file_put_contents($xmlFile, $xmlContent);
455
    }
456
457
    /**
458
     * Builds the sections array for moodle_backup.xml and for sections/* export.
459
     * Defensive: if no learnpaths are present/selected, only "General" (section 0) is emitted.
460
     * When LP exist, behavior remains unchanged.
461
     */
462
    private function getSections(): array
463
    {
464
        $sectionExport = new SectionExport($this->course);
465
        $sections = [];
466
467
        // Resolve LP bag defensively (constant or string key; or none)
468
        $lpBag =
469
            $this->course->resources[\defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath']
470
            ?? $this->course->resources['learnpath']
471
            ?? [];
472
473
        if (!empty($lpBag) && \is_array($lpBag)) {
474
            foreach ($lpBag as $learnpath) {
475
                // Unwrap if needed
476
                $lp = (\is_object($learnpath) && isset($learnpath->obj) && \is_object($learnpath->obj))
477
                    ? $learnpath->obj
478
                    : $learnpath;
479
480
                // Some exports use string '1' or int 1 for LP type = learnpath
481
                $lpType = \is_object($lp) && isset($lp->lp_type) ? (string) $lp->lp_type : '';
482
                if ('1' === $lpType) {
483
                    $sections[] = $sectionExport->getSectionData($learnpath);
484
                }
485
            }
486
        }
487
488
        // Always add "General" (section 0)
489
        $sections[] = [
490
            'id' => 0,
491
            'number' => 0,
492
            'name' => get_lang('General'),
493
            'summary' => get_lang('GeneralResourcesCourse'),
494
            'sequence' => 0,
495
            'visible' => 1,
496
            'timemodified' => time(),
497
            'activities' => $sectionExport->getActivitiesForGeneral(),
498
        ];
499
500
        return $sections;
501
    }
502
503
    private function getActivities(): array
504
    {
505
        $activities = [];
506
        $glossaryAdded = false;
507
508
        // Safely resolve the documents bucket (accept constant or plain 'document')
509
        $docBucket = [];
510
        if (\defined('RESOURCE_DOCUMENT') && isset($this->course->resources[RESOURCE_DOCUMENT]) && \is_array($this->course->resources[RESOURCE_DOCUMENT])) {
511
            $docBucket = $this->course->resources[RESOURCE_DOCUMENT];
512
        } elseif (isset($this->course->resources['document']) && \is_array($this->course->resources['document'])) {
513
            $docBucket = $this->course->resources['document'];
514
        }
515
516
        // If there are documents selected/present, add a visible "folder" activity like before
517
        if (!empty($docBucket)) {
518
            // NOTE: This creates the visible Folder activity in Moodle that groups all documents.
519
            // Files themselves will also be exported by FileExport as usual.
520
            $documentsFolder = [
521
                'id' => 0,
522
                'sectionid' => 0,
523
                'modulename' => 'folder',
524
                'moduleid' => 0,
525
                'title' => 'Documents',
526
            ];
527
            $activities[] = $documentsFolder;
528
        }
529
530
        $htmlPageIds = [];
531
532
        foreach ($this->course->resources as $resourceType => $resources) {
533
            if (!\is_array($resources) || empty($resources)) {
534
                continue;
535
            }
536
537
            foreach ($resources as $resource) {
538
                $exportClass = null;
539
                $moduleName = '';
540
                $title = '';
541
                $id = 0;
542
543
                // QUIZ
544
                if (RESOURCE_QUIZ === $resourceType && ($resource->obj->iid ?? 0) > 0) {
545
                    $exportClass = QuizExport::class;
546
                    $moduleName = 'quiz';
547
                    $id = (int) $resource->obj->iid;
548
                    $title = (string) $resource->obj->title;
549
                }
550
551
                // URL (Link)
552
                if (RESOURCE_LINK === $resourceType && ($resource->source_id ?? 0) > 0) {
553
                    $exportClass = UrlExport::class;
554
                    $moduleName = 'url';
555
                    $id = (int) $resource->source_id;
556
                    $title = (string) ($resource->title ?? '');
557
                }
558
                // GLOSSARY (only once)
559
                elseif (RESOURCE_GLOSSARY === $resourceType && ($resource->glossary_id ?? 0) > 0 && !$glossaryAdded) {
560
                    $exportClass = GlossaryExport::class;
561
                    $moduleName = 'glossary';
562
                    $id = 1;
563
                    $title = get_lang('Glossary');
564
                    $glossaryAdded = true;
565
                }
566
                // FORUM
567
                elseif (RESOURCE_FORUM === $resourceType && ($resource->source_id ?? 0) > 0) {
568
                    $exportClass = ForumExport::class;
569
                    $moduleName = 'forum';
570
                    $id = (int) ($resource->obj->iid ?? 0);
571
                    $title = (string) ($resource->obj->forum_title ?? '');
572
                }
573
                // DOCUMENT → page/resource only when it really qualifies
574
                elseif (RESOURCE_DOCUMENT === $resourceType && ($resource->source_id ?? 0) > 0) {
575
                    $resPath = (string) ($resource->path ?? '');
576
                    $resTitle = (string) ($resource->title ?? '');
577
                    $fileType = (string) ($resource->file_type ?? '');
578
579
                    // Root = only one slash (e.g., "/foo.pdf")
580
                    $isRoot = ('' !== $resPath && 1 === substr_count($resPath, '/'));
581
                    $ext = '' !== $resPath ? pathinfo($resPath, PATHINFO_EXTENSION) : '';
582
583
                    // Root HTML becomes a Moodle 'page'
584
                    if ('html' === $ext && $isRoot) {
585
                        $exportClass = PageExport::class;
586
                        $moduleName = 'page';
587
                        $id = (int) $resource->source_id;
588
                        $title = $resTitle;
589
                        $htmlPageIds[] = $id;
590
                    }
591
592
                    // Root FILE (not already exported as 'page') becomes a Moodle 'resource'
593
                    if ('file' === $fileType && !\in_array($resource->source_id, $htmlPageIds, true)) {
594
                        $resourceExport = new ResourceExport($this->course);
595
                        if ($resourceExport->getSectionIdForActivity((int) $resource->source_id, $resourceType) > 0) {
596
                            if ($isRoot) {
597
                                $exportClass = ResourceExport::class;
598
                                $moduleName = 'resource';
599
                                $id = (int) $resource->source_id;
600
                                $title = '' !== $resTitle ? $resTitle : (basename($resPath) ?: ('File '.$id));
601
                            }
602
                        }
603
                    }
604
                }
605
                // COURSE INTRO
606
                elseif (RESOURCE_TOOL_INTRO === $resourceType && ($resource->source_id ?? '') === 'course_homepage') {
607
                    $exportClass = PageExport::class;
608
                    $moduleName = 'page';
609
                    $id = 0;
610
                    $title = get_lang('Introduction');
611
                }
612
                // ASSIGN
613
                elseif (RESOURCE_WORK === $resourceType && ($resource->source_id ?? 0) > 0) {
614
                    $exportClass = AssignExport::class;
615
                    $moduleName = 'assign';
616
                    $id = (int) $resource->source_id;
617
                    $title = (string) ($resource->params['title'] ?? '');
618
                }
619
                // FEEDBACK (Survey)
620
                elseif (RESOURCE_SURVEY === $resourceType && ($resource->source_id ?? 0) > 0) {
621
                    $exportClass = FeedbackExport::class;
622
                    $moduleName = 'feedback';
623
                    $id = (int) $resource->source_id;
624
                    $title = (string) ($resource->params['title'] ?? '');
625
                }
626
627
                if ($exportClass && $moduleName) {
628
                    /** @var object $exportInstance */
629
                    $exportInstance = new $exportClass($this->course);
630
                    $activities[] = [
631
                        'id' => $id,
632
                        'sectionid' => $exportInstance->getSectionIdForActivity($id, $resourceType),
633
                        'modulename' => $moduleName,
634
                        'moduleid' => $id,
635
                        'title' => $title,
636
                    ];
637
                }
638
            }
639
        }
640
641
        return $activities;
642
    }
643
644
    private function exportSections(string $exportDir): void
645
    {
646
        $sections = $this->getSections();
647
        foreach ($sections as $section) {
648
            $sectionExport = new SectionExport($this->course);
649
            $sectionExport->exportSection($section['id'], $exportDir);
650
        }
651
    }
652
653
    private function createMbzFile(string $sourceDir): string
654
    {
655
        $zip = new ZipArchive();
656
        $zipFile = $sourceDir.'.mbz';
657
658
        if (true !== $zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
659
            throw new Exception(get_lang('ErrorCreatingZip'));
660
        }
661
662
        $files = new RecursiveIteratorIterator(
663
            new RecursiveDirectoryIterator($sourceDir),
664
            RecursiveIteratorIterator::LEAVES_ONLY
665
        );
666
667
        foreach ($files as $file) {
668
            if (!$file->isDir()) {
669
                $filePath = $file->getRealPath();
670
                $relativePath = substr($filePath, \strlen($sourceDir) + 1);
671
672
                if (!$zip->addFile($filePath, $relativePath)) {
673
                    throw new Exception(get_lang('ErrorAddingFileToZip').": $relativePath");
674
                }
675
            }
676
        }
677
678
        if (!$zip->close()) {
679
            throw new Exception(get_lang('ErrorClosingZip'));
680
        }
681
682
        return $zipFile;
683
    }
684
685
    private function cleanupTempDir(string $dir): void
686
    {
687
        $this->recursiveDelete($dir);
688
    }
689
690
    private function recursiveDelete(string $dir): void
691
    {
692
        $files = array_diff(scandir($dir), ['.', '..']);
693
        foreach ($files as $file) {
694
            $path = "$dir/$file";
695
            is_dir($path) ? $this->recursiveDelete($path) : unlink($path);
696
        }
697
        rmdir($dir);
698
    }
699
700
    private function exportBadgesXml(string $exportDir): void
701
    {
702
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
703
        $xmlContent .= '<badges>'.PHP_EOL;
704
        $xmlContent .= '</badges>';
705
        file_put_contents($exportDir.'/badges.xml', $xmlContent);
706
    }
707
708
    private function exportCompletionXml(string $exportDir): void
709
    {
710
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
711
        $xmlContent .= '<completions>'.PHP_EOL;
712
        $xmlContent .= '</completions>';
713
        file_put_contents($exportDir.'/completion.xml', $xmlContent);
714
    }
715
716
    private function exportGradebookXml(string $exportDir): void
717
    {
718
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
719
        $xmlContent .= '<gradebook>'.PHP_EOL;
720
        $xmlContent .= '</gradebook>';
721
        file_put_contents($exportDir.'/gradebook.xml', $xmlContent);
722
    }
723
724
    private function exportGradeHistoryXml(string $exportDir): void
725
    {
726
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
727
        $xmlContent .= '<grade_history>'.PHP_EOL;
728
        $xmlContent .= '</grade_history>';
729
        file_put_contents($exportDir.'/grade_history.xml', $xmlContent);
730
    }
731
732
    private function exportGroupsXml(string $exportDir): void
733
    {
734
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
735
        $xmlContent .= '<groups>'.PHP_EOL;
736
        $xmlContent .= '</groups>';
737
        file_put_contents($exportDir.'/groups.xml', $xmlContent);
738
    }
739
740
    private function exportOutcomesXml(string $exportDir): void
741
    {
742
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
743
        $xmlContent .= '<outcomes>'.PHP_EOL;
744
        $xmlContent .= '</outcomes>';
745
        file_put_contents($exportDir.'/outcomes.xml', $xmlContent);
746
    }
747
748
    private function exportRolesXml(string $exportDir): void
749
    {
750
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
751
        $xmlContent .= '<roles_definition>'.PHP_EOL;
752
        $xmlContent .= '  <role id="5">'.PHP_EOL;
753
        $xmlContent .= '    <name></name>'.PHP_EOL;
754
        $xmlContent .= '    <shortname>student</shortname>'.PHP_EOL;
755
        $xmlContent .= '    <nameincourse>$@NULL@$</nameincourse>'.PHP_EOL;
756
        $xmlContent .= '    <description></description>'.PHP_EOL;
757
        $xmlContent .= '    <sortorder>5</sortorder>'.PHP_EOL;
758
        $xmlContent .= '    <archetype>student</archetype>'.PHP_EOL;
759
        $xmlContent .= '  </role>'.PHP_EOL;
760
        $xmlContent .= '</roles_definition>'.PHP_EOL;
761
762
        file_put_contents($exportDir.'/roles.xml', $xmlContent);
763
    }
764
765
    private function exportScalesXml(string $exportDir): void
766
    {
767
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
768
        $xmlContent .= '<scales>'.PHP_EOL;
769
        $xmlContent .= '</scales>';
770
        file_put_contents($exportDir.'/scales.xml', $xmlContent);
771
    }
772
773
    private function exportUsersXml(string $exportDir): void
774
    {
775
        $adminData = self::getAdminUserData();
776
777
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
778
        $xmlContent .= '<users>'.PHP_EOL;
779
        $xmlContent .= '  <user id="'.$adminData['id'].'" contextid="'.$adminData['contextid'].'">'.PHP_EOL;
780
        $xmlContent .= '    <username>'.$adminData['username'].'</username>'.PHP_EOL;
781
        $xmlContent .= '    <idnumber>'.$adminData['idnumber'].'</idnumber>'.PHP_EOL;
782
        $xmlContent .= '    <email>'.$adminData['email'].'</email>'.PHP_EOL;
783
        $xmlContent .= '    <phone1>'.$adminData['phone1'].'</phone1>'.PHP_EOL;
784
        $xmlContent .= '    <phone2>'.$adminData['phone2'].'</phone2>'.PHP_EOL;
785
        $xmlContent .= '    <institution>'.$adminData['institution'].'</institution>'.PHP_EOL;
786
        $xmlContent .= '    <department>'.$adminData['department'].'</department>'.PHP_EOL;
787
        $xmlContent .= '    <address>'.$adminData['address'].'</address>'.PHP_EOL;
788
        $xmlContent .= '    <city>'.$adminData['city'].'</city>'.PHP_EOL;
789
        $xmlContent .= '    <country>'.$adminData['country'].'</country>'.PHP_EOL;
790
        $xmlContent .= '    <lastip>'.$adminData['lastip'].'</lastip>'.PHP_EOL;
791
        $xmlContent .= '    <picture>'.$adminData['picture'].'</picture>'.PHP_EOL;
792
        $xmlContent .= '    <description>'.$adminData['description'].'</description>'.PHP_EOL;
793
        $xmlContent .= '    <descriptionformat>'.$adminData['descriptionformat'].'</descriptionformat>'.PHP_EOL;
794
        $xmlContent .= '    <imagealt>'.$adminData['imagealt'].'</imagealt>'.PHP_EOL;
795
        $xmlContent .= '    <auth>'.$adminData['auth'].'</auth>'.PHP_EOL;
796
        $xmlContent .= '    <firstname>'.$adminData['firstname'].'</firstname>'.PHP_EOL;
797
        $xmlContent .= '    <lastname>'.$adminData['lastname'].'</lastname>'.PHP_EOL;
798
        $xmlContent .= '    <confirmed>'.$adminData['confirmed'].'</confirmed>'.PHP_EOL;
799
        $xmlContent .= '    <policyagreed>'.$adminData['policyagreed'].'</policyagreed>'.PHP_EOL;
800
        $xmlContent .= '    <deleted>'.$adminData['deleted'].'</deleted>'.PHP_EOL;
801
        $xmlContent .= '    <lang>'.$adminData['lang'].'</lang>'.PHP_EOL;
802
        $xmlContent .= '    <theme>'.$adminData['theme'].'</theme>'.PHP_EOL;
803
        $xmlContent .= '    <timezone>'.$adminData['timezone'].'</timezone>'.PHP_EOL;
804
        $xmlContent .= '    <firstaccess>'.$adminData['firstaccess'].'</firstaccess>'.PHP_EOL;
805
        $xmlContent .= '    <lastaccess>'.$adminData['lastaccess'].'</lastaccess>'.PHP_EOL;
806
        $xmlContent .= '    <lastlogin>'.$adminData['lastlogin'].'</lastlogin>'.PHP_EOL;
807
        $xmlContent .= '    <currentlogin>'.$adminData['currentlogin'].'</currentlogin>'.PHP_EOL;
808
        $xmlContent .= '    <mailformat>'.$adminData['mailformat'].'</mailformat>'.PHP_EOL;
809
        $xmlContent .= '    <maildigest>'.$adminData['maildigest'].'</maildigest>'.PHP_EOL;
810
        $xmlContent .= '    <maildisplay>'.$adminData['maildisplay'].'</maildisplay>'.PHP_EOL;
811
        $xmlContent .= '    <autosubscribe>'.$adminData['autosubscribe'].'</autosubscribe>'.PHP_EOL;
812
        $xmlContent .= '    <trackforums>'.$adminData['trackforums'].'</trackforums>'.PHP_EOL;
813
        $xmlContent .= '    <timecreated>'.$adminData['timecreated'].'</timecreated>'.PHP_EOL;
814
        $xmlContent .= '    <timemodified>'.$adminData['timemodified'].'</timemodified>'.PHP_EOL;
815
        $xmlContent .= '    <trustbitmask>'.$adminData['trustbitmask'].'</trustbitmask>'.PHP_EOL;
816
817
        if (isset($adminData['preferences']) && \is_array($adminData['preferences'])) {
818
            $xmlContent .= '    <preferences>'.PHP_EOL;
819
            foreach ($adminData['preferences'] as $preference) {
820
                $xmlContent .= '      <preference>'.PHP_EOL;
821
                $xmlContent .= '        <name>'.htmlspecialchars((string) $preference['name']).'</name>'.PHP_EOL;
822
                $xmlContent .= '        <value>'.htmlspecialchars((string) $preference['value']).'</value>'.PHP_EOL;
823
                $xmlContent .= '      </preference>'.PHP_EOL;
824
            }
825
            $xmlContent .= '    </preferences>'.PHP_EOL;
826
        } else {
827
            $xmlContent .= '    <preferences></preferences>'.PHP_EOL;
828
        }
829
830
        $xmlContent .= '    <roles>'.PHP_EOL;
831
        $xmlContent .= '      <role_overrides></role_overrides>'.PHP_EOL;
832
        $xmlContent .= '      <role_assignments></role_assignments>'.PHP_EOL;
833
        $xmlContent .= '    </roles>'.PHP_EOL;
834
835
        $xmlContent .= '  </user>'.PHP_EOL;
836
        $xmlContent .= '</users>';
837
838
        file_put_contents($exportDir.'/users.xml', $xmlContent);
839
    }
840
841
    private function exportBackupSettings(array $sections, array $activities): array
842
    {
843
        $settings = [
844
            ['level' => 'root', 'name' => 'filename', 'value' => 'backup-moodle-course-'.time().'.mbz'],
845
            ['level' => 'root', 'name' => 'imscc11', 'value' => '0'],
846
            ['level' => 'root', 'name' => 'users', 'value' => '1'],
847
            ['level' => 'root', 'name' => 'anonymize', 'value' => '0'],
848
            ['level' => 'root', 'name' => 'role_assignments', 'value' => '1'],
849
            ['level' => 'root', 'name' => 'activities', 'value' => '1'],
850
            ['level' => 'root', 'name' => 'blocks', 'value' => '1'],
851
            ['level' => 'root', 'name' => 'files', 'value' => '1'],
852
            ['level' => 'root', 'name' => 'filters', 'value' => '1'],
853
            ['level' => 'root', 'name' => 'comments', 'value' => '1'],
854
            ['level' => 'root', 'name' => 'badges', 'value' => '1'],
855
            ['level' => 'root', 'name' => 'calendarevents', 'value' => '1'],
856
            ['level' => 'root', 'name' => 'userscompletion', 'value' => '1'],
857
            ['level' => 'root', 'name' => 'logs', 'value' => '0'],
858
            ['level' => 'root', 'name' => 'grade_histories', 'value' => '0'],
859
            ['level' => 'root', 'name' => 'questionbank', 'value' => '1'],
860
            ['level' => 'root', 'name' => 'groups', 'value' => '1'],
861
            ['level' => 'root', 'name' => 'competencies', 'value' => '0'],
862
            ['level' => 'root', 'name' => 'customfield', 'value' => '1'],
863
            ['level' => 'root', 'name' => 'contentbankcontent', 'value' => '1'],
864
            ['level' => 'root', 'name' => 'legacyfiles', 'value' => '1'],
865
        ];
866
867
        foreach ($sections as $section) {
868
            $settings[] = [
869
                'level' => 'section',
870
                'section' => 'section_'.$section['id'],
871
                'name' => 'section_'.$section['id'].'_included',
872
                'value' => '1',
873
            ];
874
            $settings[] = [
875
                'level' => 'section',
876
                'section' => 'section_'.$section['id'],
877
                'name' => 'section_'.$section['id'].'_userinfo',
878
                'value' => '1',
879
            ];
880
        }
881
882
        foreach ($activities as $activity) {
883
            $settings[] = [
884
                'level' => 'activity',
885
                'activity' => $activity['modulename'].'_'.$activity['moduleid'],
886
                'name' => $activity['modulename'].'_'.$activity['moduleid'].'_included',
887
                'value' => '1',
888
            ];
889
            $settings[] = [
890
                'level' => 'activity',
891
                'activity' => $activity['modulename'].'_'.$activity['moduleid'],
892
                'name' => $activity['modulename'].'_'.$activity['moduleid'].'_userinfo',
893
                'value' => '1',
894
            ];
895
        }
896
897
        return $settings;
898
    }
899
}
900