Completed
Push — 1.11.x ( 7487d3...fa3774 )
by
unknown
01:59 queued 35s
created

MoodleExport::resolveLpModuleId()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
c 0
b 0
f 0
dl 0
loc 12
rs 10
cc 3
nc 3
nop 3
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, $activities);
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((int) $activity['id'], (int) $activity['sectionid']);
283
                $quizData['moduleid'] = (int) $activity['moduleid'];
284
                $questionsData[] = $quizData;
285
            }
286
        }
287
        $this->exportQuestionsXml($questionsData, $exportDir);
288
289
        $this->exportRolesXml($exportDir);
290
        $this->exportScalesXml($exportDir);
291
        $this->exportUsersXml($exportDir);
292
    }
293
294
    /**
295
     * Create the moodle_backup.xml file with the required course details.
296
     */
297
    private function createMoodleBackupXml(string $destinationDir, int $version): void
298
    {
299
        // Generate course information and backup metadata
300
        $courseInfo = api_get_course_info($this->course->code);
301
        $backupId = md5(uniqid(mt_rand(), true));
302
        $siteHash = md5(uniqid(mt_rand(), true));
303
        $wwwRoot = api_get_path(WEB_PATH);
304
305
        $courseStartDate = strtotime($courseInfo['creation_date']);
306
        $courseEndDate = $courseStartDate + (365 * 24 * 60 * 60);
307
308
        // Build the XML content for the backup
309
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
310
        $xmlContent .= '<moodle_backup>'.PHP_EOL;
311
        $xmlContent .= '  <information>'.PHP_EOL;
312
313
        $xmlContent .= '    <name>backup-'.htmlspecialchars($courseInfo['code']).'.mbz</name>'.PHP_EOL;
314
        $xmlContent .= '    <moodle_version>'.($version === 3 ? '2021051718' : '2022041900').'</moodle_version>'.PHP_EOL;
315
        $xmlContent .= '    <moodle_release>'.($version === 3 ? '3.11.18 (Build: 20231211)' : '4.x version here').'</moodle_release>'.PHP_EOL;
316
        $xmlContent .= '    <backup_version>'.($version === 3 ? '2021051700' : '2022041900').'</backup_version>'.PHP_EOL;
317
        $xmlContent .= '    <backup_release>'.($version === 3 ? '3.11' : '4.x').'</backup_release>'.PHP_EOL;
318
        $xmlContent .= '    <backup_date>'.time().'</backup_date>'.PHP_EOL;
319
        $xmlContent .= '    <mnet_remoteusers>0</mnet_remoteusers>'.PHP_EOL;
320
        $xmlContent .= '    <include_files>1</include_files>'.PHP_EOL;
321
        $xmlContent .= '    <include_file_references_to_external_content>0</include_file_references_to_external_content>'.PHP_EOL;
322
        $xmlContent .= '    <original_wwwroot>'.$wwwRoot.'</original_wwwroot>'.PHP_EOL;
323
        $xmlContent .= '    <original_site_identifier_hash>'.$siteHash.'</original_site_identifier_hash>'.PHP_EOL;
324
        $xmlContent .= '    <original_course_id>'.htmlspecialchars($courseInfo['real_id']).'</original_course_id>'.PHP_EOL;
325
        $xmlContent .= '    <original_course_format>'.get_lang('Topics').'</original_course_format>'.PHP_EOL;
326
        $xmlContent .= '    <original_course_fullname>'.htmlspecialchars($courseInfo['title']).'</original_course_fullname>'.PHP_EOL;
327
        $xmlContent .= '    <original_course_shortname>'.htmlspecialchars($courseInfo['code']).'</original_course_shortname>'.PHP_EOL;
328
        $xmlContent .= '    <original_course_startdate>'.$courseStartDate.'</original_course_startdate>'.PHP_EOL;
329
        $xmlContent .= '    <original_course_enddate>'.$courseEndDate.'</original_course_enddate>'.PHP_EOL;
330
        $xmlContent .= '    <original_course_contextid>'.$courseInfo['real_id'].'</original_course_contextid>'.PHP_EOL;
331
        $xmlContent .= '    <original_system_contextid>'.api_get_current_access_url_id().'</original_system_contextid>'.PHP_EOL;
332
333
        $xmlContent .= '    <details>'.PHP_EOL;
334
        $xmlContent .= '      <detail backup_id="'.$backupId.'">'.PHP_EOL;
335
        $xmlContent .= '        <type>course</type>'.PHP_EOL;
336
        $xmlContent .= '        <format>moodle2</format>'.PHP_EOL;
337
        $xmlContent .= '        <interactive>1</interactive>'.PHP_EOL;
338
        $xmlContent .= '        <mode>10</mode>'.PHP_EOL;
339
        $xmlContent .= '        <execution>1</execution>'.PHP_EOL;
340
        $xmlContent .= '        <executiontime>0</executiontime>'.PHP_EOL;
341
        $xmlContent .= '      </detail>'.PHP_EOL;
342
        $xmlContent .= '    </details>'.PHP_EOL;
343
344
        // Contents with activities and sections
345
        $xmlContent .= '    <contents>'.PHP_EOL;
346
347
        // Export sections dynamically and add them to the XML
348
        $sections = $this->getSections();
349
        if (!empty($sections)) {
350
            $xmlContent .= '      <sections>'.PHP_EOL;
351
            foreach ($sections as $section) {
352
                $xmlContent .= '        <section>'.PHP_EOL;
353
                $xmlContent .= '          <sectionid>'.$section['id'].'</sectionid>'.PHP_EOL;
354
                $xmlContent .= '          <title>'.htmlspecialchars($section['name']).'</title>'.PHP_EOL;
355
                $xmlContent .= '          <directory>sections/section_'.$section['id'].'</directory>'.PHP_EOL;
356
                $xmlContent .= '        </section>'.PHP_EOL;
357
            }
358
            $xmlContent .= '      </sections>'.PHP_EOL;
359
        }
360
361
        $activities = $this->getActivities();
362
        if (!empty($activities)) {
363
            $xmlContent .= '      <activities>'.PHP_EOL;
364
            foreach ($activities as $activity) {
365
                $xmlContent .= '        <activity>'.PHP_EOL;
366
                $xmlContent .= '          <moduleid>'.$activity['moduleid'].'</moduleid>'.PHP_EOL;
367
                $xmlContent .= '          <sectionid>'.$activity['sectionid'].'</sectionid>'.PHP_EOL;
368
                $xmlContent .= '          <modulename>'.htmlspecialchars($activity['modulename']).'</modulename>'.PHP_EOL;
369
                $xmlContent .= '          <title>'.htmlspecialchars($activity['title']).'</title>'.PHP_EOL;
370
                $xmlContent .= '          <directory>activities/'.$activity['modulename'].'_'.$activity['moduleid'].'</directory>'.PHP_EOL;
371
                $xmlContent .= '        </activity>'.PHP_EOL;
372
            }
373
            $xmlContent .= '      </activities>'.PHP_EOL;
374
        }
375
376
        // Course directory
377
        $xmlContent .= '      <course>'.PHP_EOL;
378
        $xmlContent .= '        <courseid>'.$courseInfo['real_id'].'</courseid>'.PHP_EOL;
379
        $xmlContent .= '        <title>'.htmlspecialchars($courseInfo['title']).'</title>'.PHP_EOL;
380
        $xmlContent .= '        <directory>course</directory>'.PHP_EOL;
381
        $xmlContent .= '      </course>'.PHP_EOL;
382
383
        $xmlContent .= '    </contents>'.PHP_EOL;
384
385
        // Backup settings
386
        $xmlContent .= '    <settings>'.PHP_EOL;
387
        $settings = $this->exportBackupSettings($sections, $activities);
388
        foreach ($settings as $setting) {
389
            $xmlContent .= '      <setting>'.PHP_EOL;
390
            $xmlContent .= '        <level>'.htmlspecialchars($setting['level']).'</level>'.PHP_EOL;
391
            $xmlContent .= '        <name>'.htmlspecialchars($setting['name']).'</name>'.PHP_EOL;
392
            $xmlContent .= '        <value>'.$setting['value'].'</value>'.PHP_EOL;
393
            if (isset($setting['section'])) {
394
                $xmlContent .= '        <section>'.htmlspecialchars($setting['section']).'</section>'.PHP_EOL;
395
            }
396
            if (isset($setting['activity'])) {
397
                $xmlContent .= '        <activity>'.htmlspecialchars($setting['activity']).'</activity>'.PHP_EOL;
398
            }
399
            $xmlContent .= '      </setting>'.PHP_EOL;
400
        }
401
        $xmlContent .= '    </settings>'.PHP_EOL;
402
403
        $xmlContent .= '  </information>'.PHP_EOL;
404
        $xmlContent .= '</moodle_backup>';
405
406
        $xmlFile = $destinationDir.'/moodle_backup.xml';
407
        file_put_contents($xmlFile, $xmlContent);
408
    }
409
410
    /**
411
     * Get all sections from the course ordered by LP display_order.
412
     * Uses the SAME activities list (and moduleid) as moodle_backup.xml.
413
     */
414
    private function getSections(?array $activities = null): array
415
    {
416
        $sections = [];
417
418
        // Compute activities once if not provided
419
        if ($activities === null) {
420
            $activities = $this->getActivities();
421
        }
422
423
        $activitiesBySection = $this->groupActivitiesBySection($activities);
424
425
        // We only need SectionExport for metadata (name/summary/visible/timemodified),
426
        // but it MUST reuse the precomputed activities to keep moduleid consistent.
427
        $sectionExport = new SectionExport($this->course, $activitiesBySection);
428
429
        // Safety: if there is no learnpath resource, return only the general section
430
        $learnpaths = $this->course->resources[RESOURCE_LEARNPATH] ?? [];
431
432
        // Sort LPs by display_order to respect the order defined in c_lp
433
        usort($learnpaths, static function ($a, $b): int {
434
            $aOrder = (int) ($a->display_order ?? 0);
435
            $bOrder = (int) ($b->display_order ?? 0);
436
437
            return $aOrder <=> $bOrder;
438
        });
439
440
        foreach ($learnpaths as $learnpath) {
441
            // We only export "real" LPs (type 1)
442
            if ((int) $learnpath->lp_type === 1) {
443
                $sections[] = $sectionExport->getSectionData($learnpath);
444
            }
445
        }
446
447
        // Add a general section for resources without a lesson
448
        $sections[] = [
449
            'id' => 0,
450
            'number' => 0,
451
            'name' => get_lang('General'),
452
            'summary' => get_lang('GeneralResourcesCourse'),
453
            'sequence' => 0,
454
            'visible' => 1,
455
            'timemodified' => time(),
456
            'activities' => $sectionExport->getActivitiesForGeneral(),
457
        ];
458
459
        return $sections;
460
    }
461
462
    /**
463
     * Get all activities from the course.
464
     * Activities are ordered by learnpath display_order when available.
465
     */
466
    private function getActivities(): array
467
    {
468
        $activities = [];
469
        // "Documents" folder pseudo-activity (always in section 0)
470
        $activities[] = [
471
            'id'         => ActivityExport::DOCS_MODULE_ID,
472
            'sectionid'  => 0,
473
            'modulename' => 'folder',
474
            'moduleid'   => ActivityExport::DOCS_MODULE_ID,
475
            'title'      => 'Documents',
476
            'order'      => 0,
477
        ];
478
479
        // Build activities from LP items (one course module per LP item)
480
        $learnpaths = $this->course->resources[RESOURCE_LEARNPATH] ?? [];
481
482
        // Sort by LP display_order to respect c_lp order
483
        usort($learnpaths, static function ($a, $b): int {
484
            return (int)($a->display_order ?? 0) <=> (int)($b->display_order ?? 0);
485
        });
486
487
        foreach ($learnpaths as $lp) {
488
            // Only "real" LPs
489
            if ((int)($lp->lp_type ?? 0) !== 1) {
490
                continue;
491
            }
492
493
            $sectionId = (int)($lp->source_id ?? 0);
494
            if ($sectionId <= 0 || empty($lp->items)) {
495
                continue;
496
            }
497
498
            foreach ($lp->items as $it) {
499
                $lpItemId = isset($it['id']) ? (int)$it['id'] : 0;
500
                $itemType = (string)($it['item_type'] ?? '');
501
                $path     = $it['path'] ?? null;
502
                $title    = (string)($it['title'] ?? '');
503
                $order    = isset($it['display_order']) ? (int)$it['display_order'] : 0;
504
505
                // Map LP item_type to Moodle modulename
506
                $moduleName = null;
507
                $instanceId = null;
508
509
                if ($itemType === 'quiz') {
510
                    $moduleName = 'quiz';
511
                    $instanceId = is_numeric($path) ? (int)$path : null;
512
                } elseif ($itemType === 'link') {
513
                    $moduleName = 'url';
514
                    $instanceId = is_numeric($path) ? (int)$path : null;
515
                } elseif ($itemType === 'student_publication') {
516
                    $moduleName = 'assign';
517
                    $instanceId = is_numeric($path) ? (int)$path : null;
518
                } elseif ($itemType === 'survey') {
519
                    $moduleName = 'feedback';
520
                    $instanceId = is_numeric($path) ? (int)$path : null;
521
                } elseif ($itemType === 'forum') {
522
                    $moduleName = 'forum';
523
                    $instanceId = is_numeric($path) ? (int)$path : null;
524
                } elseif ($itemType === 'document') {
525
                    $docId = is_numeric($path) ? (int)$path : 0;
526
                    if ($docId > 0) {
527
                        $doc = \DocumentManager::get_document_data_by_id($docId, $this->course->code);
528
                        if (!empty($doc)) {
529
                            $docPath = (string)($doc['path'] ?? '');
530
                            $ext = strtolower(pathinfo($docPath, PATHINFO_EXTENSION));
531
532
                            if ($ext === 'html' || $ext === 'htm') {
533
                                $moduleName = 'page';
534
                                $instanceId = $docId;
535
                                if ($title === '') {
536
                                    $title = (string)($doc['title'] ?? '');
537
                                }
538
                            } elseif (($doc['filetype'] ?? '') === 'file') {
539
                                $moduleName = 'resource';
540
                                $instanceId = $docId;
541
                                if ($title === '') {
542
                                    $title = (string)($doc['title'] ?? '');
543
                                }
544
                            }
545
                        }
546
                    }
547
                }
548
549
                // Skip unsupported / invalid
550
                if (empty($moduleName) || empty($instanceId)) {
551
                    continue;
552
                }
553
554
                // Generic unique course module id per LP occurrence
555
                $moduleId = $this->resolveLpModuleId($moduleName, $lpItemId, (int)$instanceId);
556
557
                $activities[] = [
558
                    'id'         => (int)$instanceId,
559
                    'sectionid'  => $sectionId,
560
                    'modulename' => $moduleName,
561
                    'moduleid'   => $moduleId,
562
                    'title'      => $title !== '' ? $title : $moduleName,
563
                    'order'      => $order,
564
                ];
565
            }
566
        }
567
568
        // Add general section activities (items not in any LP)
569
        $sectionExport = new SectionExport($this->course);
570
        $generalActivities = $sectionExport->getActivitiesForGeneral();
571
572
        foreach ($generalActivities as $ga) {
573
            // Avoid duplicating the Documents folder (we added it already)
574
            if (($ga['modulename'] ?? '') === 'folder') {
575
                continue;
576
            }
577
578
            $activities[] = [
579
                'id'         => (int)($ga['id'] ?? 0),
580
                'sectionid'  => 0,
581
                'modulename' => (string)($ga['modulename'] ?? ''),
582
                'moduleid'   => (int)($ga['moduleid'] ?? 0),
583
                'title'      => (string)($ga['name'] ?? ''),
584
                'order'      => 0,
585
            ];
586
        }
587
588
        // Sort activities per section by LP display_order
589
        $grouped  = [];
590
        $seqBySec = [];
591
592
        foreach ($activities as $a) {
593
            $sid = (int)($a['sectionid'] ?? 0);
594
            if (!isset($grouped[$sid])) {
595
                $grouped[$sid] = [];
596
                $seqBySec[$sid] = 0;
597
            }
598
599
            $ord = (int)($a['order'] ?? 0);
600
            if ($ord <= 0) {
601
                $seqBySec[$sid]++;
602
                $ord = 1000 + $seqBySec[$sid];
603
            }
604
605
            $a['_sort'] = $ord;
606
            $grouped[$sid][] = $a;
607
        }
608
609
        $sorted = [];
610
        foreach ($grouped as $sid => $list) {
611
            usort($list, static fn(array $x, array $y): int => $x['_sort'] <=> $y['_sort']);
612
            foreach ($list as $x) {
613
                unset($x['_sort'], $x['order']);
614
                $sorted[] = $x;
615
            }
616
        }
617
618
        return $sorted;
619
    }
620
621
    /**
622
     * Export the sections of the course.
623
     */
624
    private function exportSections(string $exportDir, array $activities): void
625
    {
626
        $sections = $this->getSections($activities);
627
        $activitiesBySection = $this->groupActivitiesBySection($activities);
628
629
        // Reuse ONE instance to keep any internal caches stable
630
        $sectionExport = new SectionExport($this->course, $activitiesBySection);
631
632
        foreach ($sections as $section) {
633
            $sectionExport->exportSection((int) $section['id'], $exportDir);
634
        }
635
    }
636
637
    /**
638
     * Convert MoodleExport::getActivities() output into the structure SectionExport expects.
639
     * Ensures section.xml sequence uses the same moduleid as moodle_backup.xml.
640
     */
641
    private function groupActivitiesBySection(array $activities): array
642
    {
643
        $bySection = [];
644
645
        foreach ($activities as $a) {
646
            $sid = (int) ($a['sectionid'] ?? 0);
647
648
            $bySection[$sid][] = [
649
                'id'        => (int) ($a['id'] ?? 0),
650
                'moduleid'  => (int) ($a['moduleid'] ?? 0),
651
                'modulename'=> (string) ($a['modulename'] ?? ''),
652
                'name'      => (string) ($a['title'] ?? ''),
653
                'sectionid' => $sid,
654
            ];
655
        }
656
657
        return $bySection;
658
    }
659
660
    /**
661
     * Create a .mbz (ZIP) file from the exported data.
662
     */
663
    private function createMbzFile(string $sourceDir): string
664
    {
665
        $zip = new ZipArchive();
666
        $zipFile = $sourceDir.'.mbz';
667
668
        if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
669
            throw new Exception(get_lang('ErrorCreatingZip'));
670
        }
671
672
        $files = new RecursiveIteratorIterator(
673
            new RecursiveDirectoryIterator($sourceDir),
674
            RecursiveIteratorIterator::LEAVES_ONLY
675
        );
676
677
        foreach ($files as $file) {
678
            if (!$file->isDir()) {
679
                $filePath = $file->getRealPath();
680
                $relativePath = substr($filePath, strlen($sourceDir) + 1);
681
682
                if (!$zip->addFile($filePath, $relativePath)) {
683
                    throw new Exception(get_lang('ErrorAddingFileToZip').": $relativePath");
684
                }
685
            }
686
        }
687
688
        if (!$zip->close()) {
689
            throw new Exception(get_lang('ErrorClosingZip'));
690
        }
691
692
        return $zipFile;
693
    }
694
695
    /**
696
     * Clean up the temporary directory used for export.
697
     */
698
    private function cleanupTempDir(string $dir): void
699
    {
700
        $this->recursiveDelete($dir);
701
    }
702
703
    /**
704
     * Recursively delete a directory and its contents.
705
     */
706
    private function recursiveDelete(string $dir): void
707
    {
708
        $files = array_diff(scandir($dir), ['.', '..']);
709
        foreach ($files as $file) {
710
            $path = "$dir/$file";
711
            is_dir($path) ? $this->recursiveDelete($path) : unlink($path);
712
        }
713
        rmdir($dir);
714
    }
715
716
    /**
717
     * Export badges data to XML file.
718
     */
719
    private function exportBadgesXml(string $exportDir): void
720
    {
721
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
722
        $xmlContent .= '<badges>'.PHP_EOL;
723
        $xmlContent .= '</badges>';
724
        file_put_contents($exportDir.'/badges.xml', $xmlContent);
725
    }
726
727
    /**
728
     * Export course completion data to XML file.
729
     */
730
    private function exportCompletionXml(string $exportDir): void
731
    {
732
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
733
        $xmlContent .= '<completions>'.PHP_EOL;
734
        $xmlContent .= '</completions>';
735
        file_put_contents($exportDir.'/completion.xml', $xmlContent);
736
    }
737
738
    /**
739
     * Export gradebook data to XML file.
740
     */
741
    private function exportGradebookXml(string $exportDir): void
742
    {
743
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
744
        $xmlContent .= '<gradebook>'.PHP_EOL;
745
        $xmlContent .= '</gradebook>';
746
        file_put_contents($exportDir.'/gradebook.xml', $xmlContent);
747
    }
748
749
    /**
750
     * Export grade history data to XML file.
751
     */
752
    private function exportGradeHistoryXml(string $exportDir): void
753
    {
754
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
755
        $xmlContent .= '<grade_history>'.PHP_EOL;
756
        $xmlContent .= '</grade_history>';
757
        file_put_contents($exportDir.'/grade_history.xml', $xmlContent);
758
    }
759
760
    /**
761
     * Export groups data to XML file.
762
     */
763
    private function exportGroupsXml(string $exportDir): void
764
    {
765
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
766
        $xmlContent .= '<groups>'.PHP_EOL;
767
        $xmlContent .= '</groups>';
768
        file_put_contents($exportDir.'/groups.xml', $xmlContent);
769
    }
770
771
    /**
772
     * Export outcomes data to XML file.
773
     */
774
    private function exportOutcomesXml(string $exportDir): void
775
    {
776
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
777
        $xmlContent .= '<outcomes>'.PHP_EOL;
778
        $xmlContent .= '</outcomes>';
779
        file_put_contents($exportDir.'/outcomes.xml', $xmlContent);
780
    }
781
782
    /**
783
     * Export roles data to XML file.
784
     */
785
    private function exportRolesXml(string $exportDir): void
786
    {
787
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
788
        $xmlContent .= '<roles_definition>'.PHP_EOL;
789
        $xmlContent .= '  <role id="5">'.PHP_EOL;
790
        $xmlContent .= '    <name></name>'.PHP_EOL;
791
        $xmlContent .= '    <shortname>student</shortname>'.PHP_EOL;
792
        $xmlContent .= '    <nameincourse>$@NULL@$</nameincourse>'.PHP_EOL;
793
        $xmlContent .= '    <description></description>'.PHP_EOL;
794
        $xmlContent .= '    <sortorder>5</sortorder>'.PHP_EOL;
795
        $xmlContent .= '    <archetype>student</archetype>'.PHP_EOL;
796
        $xmlContent .= '  </role>'.PHP_EOL;
797
        $xmlContent .= '</roles_definition>'.PHP_EOL;
798
799
        file_put_contents($exportDir.'/roles.xml', $xmlContent);
800
    }
801
802
    /**
803
     * Export scales data to XML file.
804
     */
805
    private function exportScalesXml(string $exportDir): void
806
    {
807
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
808
        $xmlContent .= '<scales>'.PHP_EOL;
809
        $xmlContent .= '</scales>';
810
        file_put_contents($exportDir.'/scales.xml', $xmlContent);
811
    }
812
813
    /**
814
     * Export the user XML with admin user data.
815
     */
816
    private function exportUsersXml(string $exportDir): void
817
    {
818
        $adminData = self::getAdminUserData();
819
820
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
821
        $xmlContent .= '<users>'.PHP_EOL;
822
        $xmlContent .= '  <user id="'.$adminData['id'].'" contextid="'.$adminData['contextid'].'">'.PHP_EOL;
823
        $xmlContent .= '    <username>'.$adminData['username'].'</username>'.PHP_EOL;
824
        $xmlContent .= '    <idnumber>'.$adminData['idnumber'].'</idnumber>'.PHP_EOL;
825
        $xmlContent .= '    <email>'.$adminData['email'].'</email>'.PHP_EOL;
826
        $xmlContent .= '    <phone1>'.$adminData['phone1'].'</phone1>'.PHP_EOL;
827
        $xmlContent .= '    <phone2>'.$adminData['phone2'].'</phone2>'.PHP_EOL;
828
        $xmlContent .= '    <institution>'.$adminData['institution'].'</institution>'.PHP_EOL;
829
        $xmlContent .= '    <department>'.$adminData['department'].'</department>'.PHP_EOL;
830
        $xmlContent .= '    <address>'.$adminData['address'].'</address>'.PHP_EOL;
831
        $xmlContent .= '    <city>'.$adminData['city'].'</city>'.PHP_EOL;
832
        $xmlContent .= '    <country>'.$adminData['country'].'</country>'.PHP_EOL;
833
        $xmlContent .= '    <lastip>'.$adminData['lastip'].'</lastip>'.PHP_EOL;
834
        $xmlContent .= '    <picture>'.$adminData['picture'].'</picture>'.PHP_EOL;
835
        $xmlContent .= '    <description>'.$adminData['description'].'</description>'.PHP_EOL;
836
        $xmlContent .= '    <descriptionformat>'.$adminData['descriptionformat'].'</descriptionformat>'.PHP_EOL;
837
        $xmlContent .= '    <imagealt>'.$adminData['imagealt'].'</imagealt>'.PHP_EOL;
838
        $xmlContent .= '    <auth>'.$adminData['auth'].'</auth>'.PHP_EOL;
839
        $xmlContent .= '    <firstname>'.$adminData['firstname'].'</firstname>'.PHP_EOL;
840
        $xmlContent .= '    <lastname>'.$adminData['lastname'].'</lastname>'.PHP_EOL;
841
        $xmlContent .= '    <confirmed>'.$adminData['confirmed'].'</confirmed>'.PHP_EOL;
842
        $xmlContent .= '    <policyagreed>'.$adminData['policyagreed'].'</policyagreed>'.PHP_EOL;
843
        $xmlContent .= '    <deleted>'.$adminData['deleted'].'</deleted>'.PHP_EOL;
844
        $xmlContent .= '    <lang>'.$adminData['lang'].'</lang>'.PHP_EOL;
845
        $xmlContent .= '    <theme>'.$adminData['theme'].'</theme>'.PHP_EOL;
846
        $xmlContent .= '    <timezone>'.$adminData['timezone'].'</timezone>'.PHP_EOL;
847
        $xmlContent .= '    <firstaccess>'.$adminData['firstaccess'].'</firstaccess>'.PHP_EOL;
848
        $xmlContent .= '    <lastaccess>'.$adminData['lastaccess'].'</lastaccess>'.PHP_EOL;
849
        $xmlContent .= '    <lastlogin>'.$adminData['lastlogin'].'</lastlogin>'.PHP_EOL;
850
        $xmlContent .= '    <currentlogin>'.$adminData['currentlogin'].'</currentlogin>'.PHP_EOL;
851
        $xmlContent .= '    <mailformat>'.$adminData['mailformat'].'</mailformat>'.PHP_EOL;
852
        $xmlContent .= '    <maildigest>'.$adminData['maildigest'].'</maildigest>'.PHP_EOL;
853
        $xmlContent .= '    <maildisplay>'.$adminData['maildisplay'].'</maildisplay>'.PHP_EOL;
854
        $xmlContent .= '    <autosubscribe>'.$adminData['autosubscribe'].'</autosubscribe>'.PHP_EOL;
855
        $xmlContent .= '    <trackforums>'.$adminData['trackforums'].'</trackforums>'.PHP_EOL;
856
        $xmlContent .= '    <timecreated>'.$adminData['timecreated'].'</timecreated>'.PHP_EOL;
857
        $xmlContent .= '    <timemodified>'.$adminData['timemodified'].'</timemodified>'.PHP_EOL;
858
        $xmlContent .= '    <trustbitmask>'.$adminData['trustbitmask'].'</trustbitmask>'.PHP_EOL;
859
860
        // Preferences
861
        if (isset($adminData['preferences']) && is_array($adminData['preferences'])) {
862
            $xmlContent .= '    <preferences>'.PHP_EOL;
863
            foreach ($adminData['preferences'] as $preference) {
864
                $xmlContent .= '      <preference>'.PHP_EOL;
865
                $xmlContent .= '        <name>'.htmlspecialchars($preference['name']).'</name>'.PHP_EOL;
866
                $xmlContent .= '        <value>'.htmlspecialchars($preference['value']).'</value>'.PHP_EOL;
867
                $xmlContent .= '      </preference>'.PHP_EOL;
868
            }
869
            $xmlContent .= '    </preferences>'.PHP_EOL;
870
        } else {
871
            $xmlContent .= '    <preferences></preferences>'.PHP_EOL;
872
        }
873
874
        // Roles (empty for now)
875
        $xmlContent .= '    <roles>'.PHP_EOL;
876
        $xmlContent .= '      <role_overrides></role_overrides>'.PHP_EOL;
877
        $xmlContent .= '      <role_assignments></role_assignments>'.PHP_EOL;
878
        $xmlContent .= '    </roles>'.PHP_EOL;
879
880
        $xmlContent .= '  </user>'.PHP_EOL;
881
        $xmlContent .= '</users>';
882
883
        // Save the content to the users.xml file
884
        file_put_contents($exportDir.'/users.xml', $xmlContent);
885
    }
886
887
    /**
888
     * Export the backup settings, including dynamic settings for sections and activities.
889
     */
890
    private function exportBackupSettings(array $sections, array $activities): array
891
    {
892
        // root-level settings
893
        $settings = [
894
            ['level' => 'root', 'name' => 'filename', 'value' => 'backup-moodle-course-'.time().'.mbz'],
895
            ['level' => 'root', 'name' => 'imscc11', 'value' => '0'],
896
            ['level' => 'root', 'name' => 'users', 'value' => '1'],
897
            ['level' => 'root', 'name' => 'anonymize', 'value' => '0'],
898
            ['level' => 'root', 'name' => 'role_assignments', 'value' => '1'],
899
            ['level' => 'root', 'name' => 'activities', 'value' => '1'],
900
            ['level' => 'root', 'name' => 'blocks', 'value' => '1'],
901
            ['level' => 'root', 'name' => 'files', 'value' => '1'],
902
            ['level' => 'root', 'name' => 'filters', 'value' => '1'],
903
            ['level' => 'root', 'name' => 'comments', 'value' => '1'],
904
            ['level' => 'root', 'name' => 'badges', 'value' => '1'],
905
            ['level' => 'root', 'name' => 'calendarevents', 'value' => '1'],
906
            ['level' => 'root', 'name' => 'userscompletion', 'value' => '1'],
907
            ['level' => 'root', 'name' => 'logs', 'value' => '0'],
908
            ['level' => 'root', 'name' => 'grade_histories', 'value' => '0'],
909
            ['level' => 'root', 'name' => 'questionbank', 'value' => '1'],
910
            ['level' => 'root', 'name' => 'groups', 'value' => '1'],
911
            ['level' => 'root', 'name' => 'competencies', 'value' => '0'],
912
            ['level' => 'root', 'name' => 'customfield', 'value' => '1'],
913
            ['level' => 'root', 'name' => 'contentbankcontent', 'value' => '1'],
914
            ['level' => 'root', 'name' => 'legacyfiles', 'value' => '1'],
915
        ];
916
917
        // section-level settings
918
        foreach ($sections as $section) {
919
            $settings[] = [
920
                'level' => 'section',
921
                'section' => 'section_'.$section['id'],
922
                'name' => 'section_'.$section['id'].'_included',
923
                'value' => '1',
924
            ];
925
            $settings[] = [
926
                'level' => 'section',
927
                'section' => 'section_'.$section['id'],
928
                'name' => 'section_'.$section['id'].'_userinfo',
929
                'value' => '1',
930
            ];
931
        }
932
933
        // activity-level settings
934
        foreach ($activities as $activity) {
935
            $settings[] = [
936
                'level' => 'activity',
937
                'activity' => $activity['modulename'].'_'.$activity['moduleid'],
938
                'name' => $activity['modulename'].'_'.$activity['moduleid'].'_included',
939
                'value' => '1',
940
            ];
941
            $settings[] = [
942
                'level' => 'activity',
943
                'activity' => $activity['modulename'].'_'.$activity['moduleid'],
944
                'name' => $activity['modulename'].'_'.$activity['moduleid'].'_userinfo',
945
                'value' => '1',
946
            ];
947
        }
948
949
        return $settings;
950
    }
951
952
    /**
953
     * Maps module name to item_type of c_lp_item.
954
     * (c_lp_item.item_type: document, quiz, link, forum, student_publication, survey)
955
     */
956
    private function mapToLpItemType(string $moduleOrItemType): string
957
    {
958
        switch ($moduleOrItemType) {
959
            case 'page':
960
            case 'resource':
961
                return 'document';
962
            case 'assign':
963
                return 'student_publication';
964
            case 'url':
965
                return 'link';
966
            case 'feedback':
967
                return 'survey';
968
            default:
969
                return $moduleOrItemType; // quiz, forum...
970
        }
971
    }
972
973
    /** Index titles by section + type + id from the LP items (c_lp_item.title). */
974
    private function buildLpTitleIndex(): array
0 ignored issues
show
Unused Code introduced by
The method buildLpTitleIndex() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
975
    {
976
        $idx = [];
977
        if (empty($this->course->resources[RESOURCE_LEARNPATH])) {
978
            return $idx;
979
        }
980
        foreach ($this->course->resources[RESOURCE_LEARNPATH] as $lp) {
981
            $sid = (int) $lp->source_id;
982
            if (empty($lp->items)) {
983
                continue;
984
            }
985
            foreach ($lp->items as $it) {
986
                $type = $this->mapToLpItemType($it['item_type']);
987
                $rid  = (string) $it['path'];
988
                $idx[$sid][$type][$rid] = $it['title'] ?? '';
989
            }
990
        }
991
        return $idx;
992
    }
993
994
    /** Returns the LP title if it exists; otherwise, use the fallback. */
995
    private function titleFromLp(array $idx, int $sectionId, string $moduleName, int $resourceId, string $fallback): string
0 ignored issues
show
Unused Code introduced by
The method titleFromLp() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
996
    {
997
        if ($sectionId <= 0) {
998
            return $fallback;
999
        }
1000
        $type = $this->mapToLpItemType($moduleName);
1001
        $rid  = (string) $resourceId;
1002
        return $idx[$sectionId][$type][$rid] ?? $fallback;
1003
    }
1004
1005
    /**
1006
     * Generic resolver for Moodle course module id from an LP item occurrence.
1007
     * Keep folder/glossary stable (if you treat glossary as singleton).
1008
     */
1009
    private function resolveLpModuleId(string $moduleName, int $lpItemId, int $fallback): int
1010
    {
1011
        if ($lpItemId <= 0) {
1012
            return $fallback;
1013
        }
1014
1015
        // Keep special/singleton modules stable if needed
1016
        if (in_array($moduleName, ['folder', 'glossary'], true)) {
1017
            return $fallback;
1018
        }
1019
1020
        return 900000000 + $lpItemId;
1021
    }
1022
}
1023