Passed
Pull Request — 1.11.x (#6923)
by
unknown
10:03
created

MoodleExport::exportRootXmlFiles()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 24
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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