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

MoodleExport::export()   B

Complexity

Conditions 6
Paths 11

Size

Total Lines 86
Code Lines 54

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 6
eloc 54
c 2
b 0
f 0
nc 11
nop 3
dl 0
loc 86
rs 8.3814

How to fix   Long Method   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder;
8
9
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
10
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ActivityExport;
11
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\AnnouncementsForumExport;
12
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\AssignExport;
13
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\AttendanceMetaExport;
14
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\CourseCalendarExport;
15
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\FeedbackExport;
16
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ForumExport;
17
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\GlossaryExport;
18
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\GradebookMetaExport;
19
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\LabelExport;
20
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\LearnpathMetaExport;
21
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\PageExport;
22
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\QuizExport;
23
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\QuizMetaExport;
24
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ResourceExport;
25
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ThematicMetaExport;
26
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\UrlExport;
27
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\WikiExport;
28
use Exception;
29
use RecursiveDirectoryIterator;
30
use RecursiveIteratorIterator;
31
use ZipArchive;
32
33
use const PATHINFO_EXTENSION;
34
use const PHP_EOL;
35
36
/**
37
 * Class MoodleExport.
38
 * Handles the export of a Moodle course in .mbz format.
39
 */
40
class MoodleExport
41
{
42
    /**
43
     * @var object
44
     */
45
    private $course;
46
47
    /**
48
     * @var array<string,mixed>
49
     */
50
    private static $adminUserData = [];
51
52
    /**
53
     * @var bool selection flag (true when exporting only selected items)
54
     */
55
    private bool $selectionMode = false;
56
57
    protected static array $activityUserinfo = [];
58
59
    /** Synthetic module id for the News forum generated from announcements */
60
    private const ANNOUNCEMENTS_MODULE_ID = 48000001;
61
62
    /** Synthetic module id for Gradebook (Chamilo-only metadata) */
63
    private const GRADEBOOK_MODULE_ID = 48000002;
64
65
    private bool $wikiAdded = false;
66
    private const WIKI_MODULE_ID = 48000003;
67
68
    /**
69
     * Constructor to initialize the course object.
70
     *
71
     * @param object $course        Filtered legacy course (may be full or selected-only)
72
     * @param bool   $selectionMode When true, do NOT re-hydrate from complete snapshot
73
     */
74
    public function __construct(object $course, bool $selectionMode = false)
75
    {
76
        // Keep the provided (possibly filtered) course as-is.
77
        $this->course = $course;
78
        $this->selectionMode = $selectionMode;
79
80
        // Only auto-fill missing dependencies when doing a full export.
81
        // In selection mode we must not re-inject extra content ("full backup" effect).
82
        if (!$this->selectionMode) {
83
            $cb = new CourseBuilder('complete');
84
            $complete = $cb->build(0, (string) ($course->code ?? ''));
85
86
            // Fill missing resources from learnpath (full export only)
87
            $this->fillResourcesFromLearnpath($complete);
88
89
            // Fill missing quiz questions (full export only)
90
            $this->fillQuestionsFromQuiz($complete);
91
        }
92
    }
93
94
    /**
95
     * Export the Moodle course in .mbz format.
96
     *
97
     * @return string Path to the created .mbz file
98
     */
99
    public function export(string $courseId, string $exportDir, int $version)
100
    {
101
        @error_log('[MoodleExport::export] Start. courseId='.$courseId.' exportDir='.$exportDir.' version='.$version);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

101
        /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::export] Start. courseId='.$courseId.' exportDir='.$exportDir.' version='.$version);

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
102
103
        $tempDir = api_get_path(SYS_ARCHIVE_PATH).$exportDir;
104
105
        if (!is_dir($tempDir)) {
106
            if (!mkdir($tempDir, api_get_permissions_for_new_directories(), true)) {
107
                @error_log('[MoodleExport::export] ERROR cannot create tempDir='.$tempDir);
108
                throw new Exception(get_lang('Unable to create the folder.'));
109
            }
110
            @error_log('[MoodleExport::export] Created tempDir='.$tempDir);
111
        }
112
113
        $courseInfo = api_get_course_info($courseId);
114
        if (!$courseInfo) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $courseInfo of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
115
            @error_log('[MoodleExport::export] ERROR CourseNotFound id='.$courseId);
116
            throw new Exception(get_lang('Course not found'));
117
        }
118
119
        // Create Moodle backup skeleton (backup.xml + dirs)
120
        $this->createMoodleBackupXml($tempDir, $version);
121
        @error_log('[MoodleExport::export] moodle_backup.xml generated');
122
123
        //    This must happen BEFORE calling getActivities() so they are included.
124
        if (method_exists($this, 'enqueueUrlActivities')) {
125
            @error_log('[MoodleExport::export] Enqueuing URL activities …');
126
            $this->enqueueUrlActivities();
127
            @error_log('[MoodleExport::export] URL activities enqueued');
128
        } else {
129
            @error_log('[MoodleExport::export][WARN] enqueueUrlActivities() not found; skipping URL activities');
130
        }
131
132
        // Gather activities (now includes URLs)
133
        $activities = $this->getActivities();
134
        @error_log('[MoodleExport::export] Activities count='.count($activities));
135
136
        // Export course structure (sections + activities metadata)
137
        $courseExport = new CourseExport($this->course, $activities);
138
        $courseExport->exportCourse($tempDir);
139
        @error_log('[MoodleExport::export] course/ exported');
140
141
        // Page export (collect extra files from HTML pages)
142
        $pageExport = new PageExport($this->course);
143
        $pageFiles = [];
144
        $pageData = $pageExport->getData(0, 1);
145
        if (!empty($pageData['files'])) {
146
            $pageFiles = $pageData['files'];
147
        }
148
        @error_log('[MoodleExport::export] pageFiles from PageExport='.count($pageFiles));
149
150
        // Files export (documents, attachments, + pages’ files)
151
        $fileExport = new FileExport($this->course);
152
        $filesData = $fileExport->getFilesData();
153
        @error_log('[MoodleExport::export] getFilesData='.count($filesData['files'] ?? []));
154
        $filesData['files'] = array_merge($filesData['files'] ?? [], $pageFiles);
155
        @error_log('[MoodleExport::export] merged files='.count($filesData['files'] ?? []));
156
        $fileExport->exportFiles($filesData, $tempDir);
157
158
        // Sections export (topics/weeks descriptors)
159
        $this->exportSections($tempDir);
160
161
        $this->exportCourseCalendar($tempDir);
162
        $this->exportAnnouncementsForum($activities, $tempDir);
163
        $this->exportLabelActivities($activities, $tempDir);
164
        $this->exportAttendanceActivities($activities, $tempDir);
165
        $this->exportThematicActivities($activities, $tempDir);
166
        $this->exportWikiActivities($activities, $tempDir);
167
        $this->exportGradebookActivities($activities, $tempDir);
168
        $this->exportQuizMetaActivities($activities, $tempDir);
169
        $this->exportLearnpathMeta($tempDir);
170
171
        // Root XMLs (course/activities indexes)
172
        $this->exportRootXmlFiles($tempDir);
173
        @error_log('[MoodleExport::export] root XMLs exported');
174
175
        // Create .mbz archive
176
        $exportedFile = $this->createMbzFile($tempDir);
177
        @error_log('[MoodleExport::export] mbz created at '.$exportedFile);
178
179
        // Cleanup temp dir
180
        $this->cleanupTempDir($tempDir);
181
        @error_log('[MoodleExport::export] tempDir removed '.$tempDir);
182
183
        @error_log('[MoodleExport::export] Done. file='.$exportedFile);
184
        return $exportedFile;
185
    }
186
187
    /**
188
     * Export questions data to XML file.
189
     */
190
    public function exportQuestionsXml(array $questionsData, string $exportDir): void
191
    {
192
        $quizExport = new QuizExport($this->course);
193
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
194
        $xmlContent .= '<question_categories>'.PHP_EOL;
195
196
        $categoryHashes = [];
197
        foreach ($questionsData as $quiz) {
198
            // Skip empty sets defensively
199
            if (empty($quiz['questions']) || !\is_array($quiz['questions'])) {
200
                continue;
201
            }
202
203
            $first = $quiz['questions'][0] ?? [];
204
            $categoryId = $first['questioncategoryid'] ?? '1';
205
206
            $hash = md5($categoryId.($quiz['name'] ?? ''));
207
            if (isset($categoryHashes[$hash])) {
208
                continue;
209
            }
210
            $categoryHashes[$hash] = true;
211
            $xmlContent .= '  <question_category id="'.$categoryId.'">'.PHP_EOL;
212
            $xmlContent .= '    <name>Default for '.htmlspecialchars((string) ($quiz['name'] ?? 'Unknown')).'</name>'.PHP_EOL;
213
            $xmlContent .= '    <contextid>'.($quiz['contextid'] ?? '0').'</contextid>'.PHP_EOL;
214
            $xmlContent .= '    <contextlevel>70</contextlevel>'.PHP_EOL;
215
            $xmlContent .= '    <contextinstanceid>'.($quiz['moduleid'] ?? '0').'</contextinstanceid>'.PHP_EOL;
216
            $xmlContent .= '    <info>The default category for questions shared in context "'.htmlspecialchars((string) ($quiz['name'] ?? 'Unknown')).'".</info>'.PHP_EOL;
217
            $xmlContent .= '    <infoformat>0</infoformat>'.PHP_EOL;
218
            $xmlContent .= '    <stamp>moodle+'.time().'+CATEGORYSTAMP</stamp>'.PHP_EOL;
219
            $xmlContent .= '    <parent>0</parent>'.PHP_EOL;
220
            $xmlContent .= '    <sortorder>999</sortorder>'.PHP_EOL;
221
            $xmlContent .= '    <idnumber>$@NULL@$</idnumber>'.PHP_EOL;
222
            $xmlContent .= '    <questions>'.PHP_EOL;
223
224
            foreach ($quiz['questions'] as $question) {
225
                $xmlContent .= $quizExport->exportQuestion($question);
226
            }
227
228
            $xmlContent .= '    </questions>'.PHP_EOL;
229
            $xmlContent .= '  </question_category>'.PHP_EOL;
230
        }
231
232
        $xmlContent .= '</question_categories>';
233
        file_put_contents($exportDir.'/questions.xml', $xmlContent);
234
    }
235
236
    /**
237
     * Sets the admin user data.
238
     */
239
    public function setAdminUserData(int $id, string $username, string $email): void
240
    {
241
        self::$adminUserData = [
242
            'id' => $id,
243
            'contextid' => $id,
244
            'username' => $username,
245
            'idnumber' => '',
246
            'email' => $email,
247
            'phone1' => '',
248
            'phone2' => '',
249
            'institution' => '',
250
            'department' => '',
251
            'address' => '',
252
            'city' => 'London',
253
            'country' => 'GB',
254
            'lastip' => '127.0.0.1',
255
            'picture' => '0',
256
            'description' => '',
257
            'descriptionformat' => 1,
258
            'imagealt' => '$@NULL@$',
259
            'auth' => 'manual',
260
            'firstname' => 'Admin',
261
            'lastname' => 'User',
262
            'confirmed' => 1,
263
            'policyagreed' => 0,
264
            'deleted' => 0,
265
            'lang' => 'en',
266
            'theme' => '',
267
            'timezone' => 99,
268
            'firstaccess' => time(),
269
            'lastaccess' => time() - (60 * 60 * 24 * 7),
270
            'lastlogin' => time() - (60 * 60 * 24 * 2),
271
            'currentlogin' => time(),
272
            'mailformat' => 1,
273
            'maildigest' => 0,
274
            'maildisplay' => 1,
275
            'autosubscribe' => 1,
276
            'trackforums' => 0,
277
            'timecreated' => time(),
278
            'timemodified' => time(),
279
            'trustbitmask' => 0,
280
            'preferences' => [
281
                ['name' => 'core_message_migrate_data', 'value' => 1],
282
                ['name' => 'auth_manual_passwordupdatetime', 'value' => time()],
283
                ['name' => 'email_bounce_count', 'value' => 1],
284
                ['name' => 'email_send_count', 'value' => 1],
285
                ['name' => 'login_failed_count_since_success', 'value' => 0],
286
                ['name' => 'filepicker_recentrepository', 'value' => 5],
287
                ['name' => 'filepicker_recentlicense', 'value' => 'unknown'],
288
            ],
289
        ];
290
    }
291
292
    /**
293
     * Returns hardcoded data for the admin user.
294
     *
295
     * @return array<string,mixed>
296
     */
297
    public static function getAdminUserData(): array
298
    {
299
        return self::$adminUserData;
300
    }
301
302
    public static function flagActivityUserinfo(string $modname, int $moduleId, bool $hasUserinfo): void
303
    {
304
        self::$activityUserinfo[$modname][$moduleId] = $hasUserinfo;
305
    }
306
307
    /**
308
     * Robustly checks if a resource type matches a constant or any string aliases.
309
     * This prevents "undefined constant" notices and supports mixed key styles.
310
     *
311
     * @param mixed         $resourceType Numeric constant or string like 'quiz', 'document', etc.
312
     * @param string        $constName    Constant name, e.g. 'RESOURCE_QUIZ'
313
     * @param array<string> $aliases      String aliases accepted for this type
314
     */
315
    private function isType($resourceType, string $constName, array $aliases = []): bool
316
    {
317
        // Match numeric constant exactly when defined
318
        if (\defined($constName)) {
319
            $constVal = \constant($constName);
320
            if ($resourceType === $constVal) {
321
                return true;
322
            }
323
        }
324
325
        // Match by string aliases (case-insensitive) if resourceType is a string
326
        if (\is_string($resourceType)) {
327
            $rt = mb_strtolower($resourceType);
328
            foreach ($aliases as $a) {
329
                if ($rt === mb_strtolower($a)) {
330
                    return true;
331
                }
332
            }
333
        }
334
335
        return false;
336
    }
337
338
    /**
339
     * Pulls dependent resources that LP items reference (only when LP bag exists).
340
     * Defensive: if no learnpath bag is present (e.g., exporting only documents),
341
     * this becomes a no-op. Keeps current behavior untouched when LP exist.
342
     */
343
    private function fillResourcesFromLearnpath(object $complete): void
344
    {
345
        // Accept both constant and plain-string keys defensively.
346
        $lpBag =
347
            $this->course->resources[\defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath']
348
            ?? $this->course->resources['learnpath']
349
            ?? [];
350
351
        if (empty($lpBag) || !\is_array($lpBag)) {
352
            // No learnpaths selected/present → nothing to hydrate.
353
            return;
354
        }
355
356
        foreach ($lpBag as $learnpathId => $learnpath) {
357
            // $learnpath may be wrapped in ->obj
358
            $lp = (\is_object($learnpath) && isset($learnpath->obj) && \is_object($learnpath->obj))
359
                ? $learnpath->obj
360
                : $learnpath;
361
362
            if (!\is_object($lp) || empty($lp->items) || !\is_array($lp->items)) {
363
                continue;
364
            }
365
366
            foreach ($lp->items as $item) {
367
                // Legacy LP items expose "item_type" and "path" (resource id)
368
                $type = $item['item_type'] ?? null;
369
                $resourceId = $item['path'] ?? null;
370
                if (!$type || null === $resourceId) {
371
                    continue;
372
                }
373
374
                // Bring missing deps from the complete snapshot (keeps old behavior when LP exist)
375
                if (isset($complete->resources[$type][$resourceId])
376
                    && !isset($this->course->resources[$type][$resourceId])) {
377
                    $this->course->resources[$type][$resourceId] = $complete->resources[$type][$resourceId];
378
                }
379
            }
380
        }
381
    }
382
383
    private function fillQuestionsFromQuiz(object $complete): void
384
    {
385
        if (!isset($this->course->resources['quiz'])) {
386
            return;
387
        }
388
        foreach ($this->course->resources['quiz'] as $quizId => $quiz) {
389
            if (!isset($quiz->obj->question_ids)) {
390
                continue;
391
            }
392
            foreach ($quiz->obj->question_ids as $questionId) {
393
                if (isset($complete->resources['Exercise_Question'][$questionId]) && !isset($this->course->resources['Exercise_Question'][$questionId])) {
394
                    $this->course->resources['Exercise_Question'][$questionId] = $complete->resources['Exercise_Question'][$questionId];
395
                }
396
            }
397
        }
398
    }
399
400
    private function exportRootXmlFiles(string $exportDir): void
401
    {
402
        $this->exportBadgesXml($exportDir);
403
        $this->exportCompletionXml($exportDir);
404
        $this->exportGradebookXml($exportDir);
405
        $this->exportGradeHistoryXml($exportDir);
406
        $this->exportGroupsXml($exportDir);
407
        $this->exportOutcomesXml($exportDir);
408
409
        $activities = $this->getActivities();
410
        $questionsData = [];
411
        foreach ($activities as $activity) {
412
            if ('quiz' === ($activity['modulename'] ?? '')) {
413
                $quizExport = new QuizExport($this->course);
414
                $quizData = $quizExport->getData($activity['id'], $activity['sectionid']);
415
                $questionsData[] = $quizData;
416
            }
417
        }
418
        $this->exportQuestionsXml($questionsData, $exportDir);
419
420
        $this->exportRolesXml($exportDir);
421
        $this->exportScalesXml($exportDir);
422
        $this->exportUsersXml($exportDir);
423
    }
424
425
    private function createMoodleBackupXml(string $destinationDir, int $version): void
426
    {
427
        $courseInfo = api_get_course_info($this->course->code);
428
        $backupId = md5(bin2hex(random_bytes(16)));
429
        $siteHash = md5(bin2hex(random_bytes(16)));
430
        $wwwRoot = api_get_path(WEB_PATH);
431
432
        $courseStartDate = strtotime($courseInfo['creation_date']);
433
        $courseEndDate = $courseStartDate + (365 * 24 * 60 * 60);
434
435
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
436
        $xmlContent .= '<moodle_backup>'.PHP_EOL;
437
        $xmlContent .= '  <information>'.PHP_EOL;
438
439
        $xmlContent .= '    <name>backup-'.htmlspecialchars((string) $courseInfo['code']).'.mbz</name>'.PHP_EOL;
440
        $xmlContent .= '    <moodle_version>'.(3 === $version ? '2021051718' : '2022041900').'</moodle_version>'.PHP_EOL;
441
        $xmlContent .= '    <moodle_release>'.(3 === $version ? '3.11.18 (Build: 20231211)' : '4.x version here').'</moodle_release>'.PHP_EOL;
442
        $xmlContent .= '    <backup_version>'.(3 === $version ? '2021051700' : '2022041900').'</backup_version>'.PHP_EOL;
443
        $xmlContent .= '    <backup_release>'.(3 === $version ? '3.11' : '4.x').'</backup_release>'.PHP_EOL;
444
        $xmlContent .= '    <backup_date>'.time().'</backup_date>'.PHP_EOL;
445
        $xmlContent .= '    <mnet_remoteusers>0</mnet_remoteusers>'.PHP_EOL;
446
        $xmlContent .= '    <include_files>1</include_files>'.PHP_EOL;
447
        $xmlContent .= '    <include_file_references_to_external_content>0</include_file_references_to_external_content>'.PHP_EOL;
448
        $xmlContent .= '    <original_wwwroot>'.$wwwRoot.'</original_wwwroot>'.PHP_EOL;
449
        $xmlContent .= '    <original_site_identifier_hash>'.$siteHash.'</original_site_identifier_hash>'.PHP_EOL;
450
        $xmlContent .= '    <original_course_id>'.htmlspecialchars((string) $courseInfo['real_id']).'</original_course_id>'.PHP_EOL;
451
        $xmlContent .= '    <original_course_format>'.get_lang('Topics').'</original_course_format>'.PHP_EOL;
452
        $xmlContent .= '    <original_course_fullname>'.htmlspecialchars((string) $courseInfo['title']).'</original_course_fullname>'.PHP_EOL;
453
        $xmlContent .= '    <original_course_shortname>'.htmlspecialchars((string) $courseInfo['code']).'</original_course_shortname>'.PHP_EOL;
454
        $xmlContent .= '    <original_course_startdate>'.$courseStartDate.'</original_course_startdate>'.PHP_EOL;
455
        $xmlContent .= '    <original_course_enddate>'.$courseEndDate.'</original_course_enddate>'.PHP_EOL;
456
        $xmlContent .= '    <original_course_contextid>'.$courseInfo['real_id'].'</original_course_contextid>'.PHP_EOL;
457
        $xmlContent .= '    <original_system_contextid>'.api_get_current_access_url_id().'</original_system_contextid>'.PHP_EOL;
458
459
        $xmlContent .= '    <details>'.PHP_EOL;
460
        $xmlContent .= '      <detail backup_id="'.$backupId.'">'.PHP_EOL;
461
        $xmlContent .= '        <type>course</type>'.PHP_EOL;
462
        $xmlContent .= '        <format>moodle2</format>'.PHP_EOL;
463
        $xmlContent .= '        <interactive>1</interactive>'.PHP_EOL;
464
        $xmlContent .= '        <mode>10</mode>'.PHP_EOL;
465
        $xmlContent .= '        <execution>1</execution>'.PHP_EOL;
466
        $xmlContent .= '        <executiontime>0</executiontime>'.PHP_EOL;
467
        $xmlContent .= '      </detail>'.PHP_EOL;
468
        $xmlContent .= '    </details>'.PHP_EOL;
469
470
        $xmlContent .= '    <contents>'.PHP_EOL;
471
472
        $sections = $this->getSections();
473
        if (!empty($sections)) {
474
            $xmlContent .= '      <sections>'.PHP_EOL;
475
            foreach ($sections as $section) {
476
                $xmlContent .= '        <section>'.PHP_EOL;
477
                $xmlContent .= '          <sectionid>'.$section['id'].'</sectionid>'.PHP_EOL;
478
                $xmlContent .= '          <title>'.htmlspecialchars((string) $section['name']).'</title>'.PHP_EOL;
479
                $xmlContent .= '          <directory>sections/section_'.$section['id'].'</directory>'.PHP_EOL;
480
                $xmlContent .= '        </section>'.PHP_EOL;
481
            }
482
            $xmlContent .= '      </sections>'.PHP_EOL;
483
        }
484
485
        $seenActs = [];
486
        $activitiesFlat = [];
487
        foreach ($sections as $section) {
488
            foreach ($section['activities'] as $a) {
489
                $modname = (string) ($a['modulename'] ?? '');
490
                $moduleid = isset($a['moduleid']) ? (int) $a['moduleid'] : null;
491
                if ('' === $modname || null === $moduleid || $moduleid < 0) {
492
                    continue;
493
                }
494
                $key = $modname.':'.$moduleid;
495
                if (isset($seenActs[$key])) {
496
                    continue;
497
                }
498
                $seenActs[$key] = true;
499
500
                $title = (string) ($a['title'] ?? $a['name'] ?? '');
501
                $activitiesFlat[] = [
502
                    'moduleid' => $moduleid,
503
                    'sectionid' => (int) $section['id'],
504
                    'modulename' => $modname,
505
                    'title' => $title,
506
                ];
507
            }
508
        }
509
510
        // Append label/forum/wiki from getActivities() that are not already listed by sections
511
        foreach ($this->getActivities() as $a) {
512
            $modname  = (string) ($a['modulename'] ?? '');
513
            if (!\in_array($modname, ['label','forum','wiki'], true)) {
514
                continue;
515
            }
516
517
            $moduleid = (int) ($a['moduleid'] ?? 0);
518
            if ($moduleid <= 0) {
519
                continue;
520
            }
521
522
            $key = $modname.':'.$moduleid;
523
            if (isset($seenActs[$key])) {
524
                continue; // already present via sections, skip to avoid duplicates
525
            }
526
            $seenActs[$key] = true;
527
528
            // Ensure we propagate title and section for the backup XML
529
            $activitiesFlat[] = [
530
                'moduleid'  => $moduleid,
531
                'sectionid' => (int) ($a['sectionid'] ?? 0),
532
                'modulename'=> $modname,
533
                'title'     => (string) ($a['title'] ?? ''),
534
            ];
535
        }
536
537
        if (!empty($activitiesFlat)) {
538
            $xmlContent .= '      <activities>'.PHP_EOL;
539
            foreach ($activitiesFlat as $activity) {
540
                $modname  = (string) $activity['modulename'];
541
                $moduleid = (int)    $activity['moduleid'];
542
                $sectionid= (int)    $activity['sectionid'];
543
                $title    = (string) $activity['title'];
544
545
                $hasUserinfo = self::$activityUserinfo[$modname][$moduleid] ?? false;
546
547
                $xmlContent .= '        <activity>'.PHP_EOL;
548
                $xmlContent .= '          <moduleid>'.$moduleid.'</moduleid>'.PHP_EOL;
549
                $xmlContent .= '          <sectionid>'.$sectionid.'</sectionid>'.PHP_EOL;
550
                $xmlContent .= '          <modulename>'.htmlspecialchars($modname).'</modulename>'.PHP_EOL;
551
                $xmlContent .= '          <title>'.htmlspecialchars($title).'</title>'.PHP_EOL;
552
                $xmlContent .= '          <directory>activities/'.$modname.'_'.$moduleid.'</directory>'.PHP_EOL;
553
                $xmlContent .= '          <userinfo>'.($hasUserinfo ? '1' : '0').'</userinfo>'.PHP_EOL;
554
                $xmlContent .= '        </activity>'.PHP_EOL;
555
            }
556
            $xmlContent .= '      </activities>'.PHP_EOL;
557
        }
558
559
        $xmlContent .= '      <course>'.PHP_EOL;
560
        $xmlContent .= '        <courseid>'.$courseInfo['real_id'].'</courseid>'.PHP_EOL;
561
        $xmlContent .= '        <title>'.htmlspecialchars((string) $courseInfo['title']).'</title>'.PHP_EOL;
562
        $xmlContent .= '        <directory>course</directory>'.PHP_EOL;
563
        $xmlContent .= '      </course>'.PHP_EOL;
564
565
        $xmlContent .= '    </contents>'.PHP_EOL;
566
567
        $xmlContent .= '    <settings>'.PHP_EOL;
568
        $activities = $activitiesFlat;
569
        $settings = $this->exportBackupSettings($sections, $activities);
570
        foreach ($settings as $setting) {
571
            $xmlContent .= '      <setting>'.PHP_EOL;
572
            $xmlContent .= '        <level>'.htmlspecialchars($setting['level']).'</level>'.PHP_EOL;
573
            $xmlContent .= '        <name>'.htmlspecialchars($setting['name']).'</name>'.PHP_EOL;
574
            $xmlContent .= '        <value>'.$setting['value'].'</value>'.PHP_EOL;
575
            if (isset($setting['section'])) {
576
                $xmlContent .= '        <section>'.htmlspecialchars($setting['section']).'</section>'.PHP_EOL;
577
            }
578
            if (isset($setting['activity'])) {
579
                $xmlContent .= '        <activity>'.htmlspecialchars($setting['activity']).'</activity>'.PHP_EOL;
580
            }
581
            $xmlContent .= '      </setting>'.PHP_EOL;
582
        }
583
        $xmlContent .= '    </settings>'.PHP_EOL;
584
585
        $xmlContent .= '  </information>'.PHP_EOL;
586
        $xmlContent .= '</moodle_backup>';
587
588
        $xmlFile = $destinationDir.'/moodle_backup.xml';
589
        file_put_contents($xmlFile, $xmlContent);
590
    }
591
592
    /**
593
     * Builds the sections array for moodle_backup.xml and for sections/* export.
594
     * Defensive: if no learnpaths are present/selected, only "General" (section 0) is emitted.
595
     * When LP exist, behavior remains unchanged.
596
     */
597
    private function getSections(): array
598
    {
599
        $sectionExport = new SectionExport($this->course);
600
        $sections = [];
601
602
        // Resolve LP bag defensively (constant or string key; or none)
603
        $lpBag =
604
            $this->course->resources[\defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath']
605
            ?? $this->course->resources['learnpath']
606
            ?? [];
607
608
        if (!empty($lpBag) && \is_array($lpBag)) {
609
            foreach ($lpBag as $learnpath) {
610
                // Unwrap if needed
611
                $lp = (\is_object($learnpath) && isset($learnpath->obj) && \is_object($learnpath->obj))
612
                    ? $learnpath->obj
613
                    : $learnpath;
614
615
                // Some exports use string '1' or int 1 for LP type = learnpath
616
                $lpType = \is_object($lp) && isset($lp->lp_type) ? (string) $lp->lp_type : '';
617
                if ('1' === $lpType) {
618
                    $sections[] = $sectionExport->getSectionData($learnpath);
619
                }
620
            }
621
        }
622
623
        // Always add "General" (section 0)
624
        $sections[] = [
625
            'id' => 0,
626
            'number' => 0,
627
            'name' => get_lang('General'),
628
            'summary' => get_lang('General course resources'),
629
            'sequence' => 0,
630
            'visible' => 1,
631
            'timemodified' => time(),
632
            'activities' => $sectionExport->getActivitiesForGeneral(),
633
        ];
634
635
        return $sections;
636
    }
637
638
    private function getActivities(): array
639
    {
640
        @error_log('[MoodleExport::getActivities] Start');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

640
        /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::getActivities] Start');

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
641
642
        $activities = [];
643
        $glossaryAdded = false;
644
        $wikiAdded = false;
645
646
        // Build a "documents" bucket (root-level files/folders)
647
        $docBucket = [];
648
        if (\defined('RESOURCE_DOCUMENT') && isset($this->course->resources[RESOURCE_DOCUMENT]) && \is_array($this->course->resources[RESOURCE_DOCUMENT])) {
649
            $docBucket = $this->course->resources[RESOURCE_DOCUMENT];
650
        } elseif (isset($this->course->resources['document']) && \is_array($this->course->resources['document'])) {
651
            $docBucket = $this->course->resources['document'];
652
        }
653
        @error_log('[MoodleExport::getActivities] docBucket='.count($docBucket));
654
655
        // Add a visible "Documents" folder activity if we actually have documents
656
        if (!empty($docBucket)) {
657
            $activities[] = [
658
                'id'        => ActivityExport::DOCS_MODULE_ID,
659
                'sectionid' => 0,
660
                'modulename'=> 'folder',
661
                'moduleid'  => ActivityExport::DOCS_MODULE_ID,
662
                'title'     => 'Documents',
663
            ];
664
            @error_log('[MoodleExport::getActivities] Added visible folder activity "Documents" (moduleid=' . ActivityExport::DOCS_MODULE_ID . ').');
665
        }
666
667
        $htmlPageIds = [];
668
669
        foreach ($this->course->resources as $resourceType => $resources) {
670
            if (!\is_array($resources) || empty($resources)) {
671
                continue;
672
            }
673
674
            foreach ($resources as $resource) {
675
                $exportClass = null;
676
                $moduleName = '';
677
                $title = '';
678
                $id = 0;
679
680
                // Quiz
681
                if ($this->isType($resourceType, 'RESOURCE_QUIZ', ['quiz'])) {
682
                    if (($resource->obj->iid ?? 0) > 0) {
683
                        $exportClass = QuizExport::class;
684
                        $moduleName = 'quiz';
685
                        $id = (int) $resource->obj->iid;
686
                        $title = (string) $resource->obj->title;
687
                    }
688
                }
689
690
                // URL
691
                if ($this->isType($resourceType, 'RESOURCE_LINK', ['link'])) {
692
                    if (($resource->source_id ?? 0) > 0) {
693
                        $exportClass = UrlExport::class;
694
                        $moduleName = 'url';
695
                        $id = (int) $resource->source_id;
696
                        $title = (string) ($resource->title ?? '');
697
                    }
698
                }
699
                // Glossary (only once)
700
                elseif ($this->isType($resourceType, 'RESOURCE_GLOSSARY', ['glossary'])) {
701
                    if (($resource->glossary_id ?? 0) > 0 && !$glossaryAdded) {
702
                        $exportClass = GlossaryExport::class;
703
                        $moduleName = 'glossary';
704
                        $id = 1;
705
                        $title = get_lang('Glossary');
706
                        $glossaryAdded = true;
707
                        self::flagActivityUserinfo('glossary', $id, true);
708
                    }
709
                }
710
                // Forum
711
                elseif ($this->isType($resourceType, 'RESOURCE_FORUM', ['forum'])) {
712
                    if (($resource->source_id ?? 0) > 0) {
713
                        $exportClass = ForumExport::class;
714
                        $moduleName = 'forum';
715
                        $id = (int) ($resource->obj->iid ?? 0);
716
                        $title = (string) ($resource->obj->forum_title ?? '');
717
                        self::flagActivityUserinfo('forum', $id, true);
718
                    }
719
                }
720
                // Documents (as Page or Resource)
721
                elseif ($this->isType($resourceType, 'RESOURCE_DOCUMENT', ['document'])) {
722
                    $resPath = (string) ($resource->path ?? '');
723
                    $resTitle = (string) ($resource->title ?? '');
724
                    $fileType = (string) ($resource->file_type ?? '');
725
726
                    $isRoot = ('' !== $resPath && 1 === substr_count($resPath, '/'));
727
                    $ext = '' !== $resPath ? pathinfo($resPath, PATHINFO_EXTENSION) : '';
728
729
                    // Root HTML -> export as "page"
730
                    if ('html' === $ext && $isRoot) {
731
                        $exportClass = PageExport::class;
732
                        $moduleName = 'page';
733
                        $id = (int) $resource->source_id;
734
                        $title = $resTitle;
735
                        $htmlPageIds[] = $id;
736
                    }
737
738
                    // Regular file -> export as "resource" (avoid colliding with pages)
739
                    if ('file' === $fileType && !\in_array($resource->source_id, $htmlPageIds, true)) {
740
                        $resourceExport = new ResourceExport($this->course);
741
                        if ($resourceExport->getSectionIdForActivity((int) $resource->source_id, $resourceType) > 0) {
742
                            if ($isRoot) {
743
                                $exportClass = ResourceExport::class;
744
                                $moduleName = 'resource';
745
                                $id = (int) $resource->source_id;
746
                                $title = '' !== $resTitle ? $resTitle : (basename($resPath) ?: ('File '.$id));
747
                            }
748
                        }
749
                    }
750
                }
751
                // Tool Intro -> treat "course_homepage" as a Page activity (id=0)
752
                elseif ($this->isType($resourceType, 'RESOURCE_TOOL_INTRO', ['tool_intro'])) {
753
                    // IMPORTANT: do not check source_id; the real key is obj->id
754
                    $objId = (string) ($resource->obj->id ?? '');
755
                    if ($objId === 'course_homepage') {
756
                        $exportClass = PageExport::class;
757
                        $moduleName = 'page';
758
                        // Keep activity id = 0 → PageExport::getData(0, ...) reads the intro HTML
759
                        $id = 0;
760
                        $title = get_lang('Introduction');
761
                    }
762
                }
763
                // Assignments
764
                elseif ($this->isType($resourceType, 'RESOURCE_WORK', ['work', 'assign'])) {
765
                    if (($resource->source_id ?? 0) > 0) {
766
                        $exportClass = AssignExport::class;
767
                        $moduleName = 'assign';
768
                        $id = (int) $resource->source_id;
769
                        $title = (string) ($resource->params['title'] ?? '');
770
                    }
771
                }
772
                // Surveys -> Feedback
773
                elseif ($this->isType($resourceType, 'RESOURCE_SURVEY', ['survey', 'feedback'])) {
774
                    if (($resource->source_id ?? 0) > 0) {
775
                        $exportClass = FeedbackExport::class;
776
                        $moduleName = 'feedback';
777
                        $id = (int) $resource->source_id;
778
                        $title = (string) ($resource->params['title'] ?? '');
779
                    }
780
                }
781
                // Course descriptions → Label
782
                elseif ($this->isType($resourceType, 'RESOURCE_COURSEDESCRIPTION', ['coursedescription', 'course_description'])) {
783
                    if (($resource->source_id ?? 0) > 0) {
784
                        $exportClass = LabelExport::class;
785
                        $moduleName  = 'label';
786
                        $id          = (int) $resource->source_id;
787
                        $title       = (string) ($resource->title ?? '');
788
                    }
789
                }
790
                // Attendance (store as Chamilo-only metadata; NOT a Moodle activity)
791
                elseif ($this->isType($resourceType, 'RESOURCE_ATTENDANCE', ['attendance'])) {
792
                    // Resolve legacy id (iid) from possible fields
793
                    $id = 0;
794
                    if (isset($resource->obj->iid) && \is_numeric($resource->obj->iid)) {
795
                        $id = (int) $resource->obj->iid;
796
                    } elseif (isset($resource->source_id) && \is_numeric($resource->source_id)) {
797
                        $id = (int) $resource->source_id;
798
                    } elseif (isset($resource->obj->id) && \is_numeric($resource->obj->id)) {
799
                        $id = (int) $resource->obj->id;
800
                    }
801
802
                    // Resolve title or fallback
803
                    $title = '';
804
                    foreach (['title','name'] as $k) {
805
                        if (!empty($resource->obj->{$k}) && \is_string($resource->obj->{$k})) { $title = trim((string)$resource->obj->{$k}); break; }
806
                        if (!empty($resource->{$k}) && \is_string($resource->{$k}))       { $title = trim((string)$resource->{$k}); break; }
807
                    }
808
                    if ($title === '') { $title = 'Attendance'; }
809
810
                    // Section: best-effort (0 when unknown). We avoid calling any Attendance exporter here.
811
                    $sectionId = 0;
812
813
                    // IMPORTANT:
814
                    // We register it with a pseudo module "attendance" for our own export step,
815
                    // but we do NOT emit a Moodle activity nor include it in moodle_backup.xml.
816
                    $activities[] = [
817
                        'id'         => $id,
818
                        'sectionid'  => $sectionId,
819
                        'modulename' => 'attendance',
820
                        'moduleid'   => $id,
821
                        'title'      => $title,
822
                        '__from'     => 'attendance',
823
                    ];
824
825
                    @error_log('[MoodleExport::getActivities] ADD (Chamilo-only) attendance moduleid='.$id.' sectionid='.$sectionId.' title="'.str_replace(["\n","\r"],' ',$title).'"');
826
                    // do NOT set $exportClass → keeps getActivities() side-effect free for Moodle
827
                }
828
                // Thematic (Chamilo-only metadata)
829
                elseif ($this->isType($resourceType, 'RESOURCE_THEMATIC', ['thematic'])) {
830
                    $id = (int) ($resource->obj->iid ?? $resource->source_id ?? $resource->obj->id ?? 0);
831
                    if ($id > 0) {
832
                        $title = '';
833
                        foreach (['title','name'] as $k) {
834
                            if (!empty($resource->obj->{$k})) { $title = trim((string) $resource->obj->{$k}); break; }
835
                            if (!empty($resource->{$k}))       { $title = trim((string) $resource->{$k}); break; }
836
                        }
837
                        if ($title === '') $title = 'Thematic';
838
839
                        $activities[] = [
840
                            'id'         => $id,
841
                            'sectionid'  => 0,            // or the real topic if you track it
842
                            'modulename' => 'thematic',   // Chamilo-only meta
843
                            'moduleid'   => $id,
844
                            'title'      => $title,
845
                            '__from'     => 'thematic',
846
                        ];
847
                        @error_log('[MoodleExport::getActivities] ADD (Chamilo-only) thematic id='.$id);
848
                    }
849
                }
850
                // Wiki (only once)
851
                elseif ($this->isType($resourceType, 'RESOURCE_WIKI', ['wiki'])) {
852
                    if (!$wikiAdded) {
853
                        $exportClass = WikiExport::class;
854
                        $moduleName  = 'wiki';
855
                        $id          = self::WIKI_MODULE_ID;
856
                        $title       = get_lang('Wiki') ?: 'Wiki';
857
                        $wikiAdded = true;
858
859
                        self::flagActivityUserinfo('wiki', $id, true);
860
                    } else {
861
                        continue;
862
                    }
863
                }
864
                // Gradebook (Chamilo-only; exports chamilo/gradebook/*.json; NOT a Moodle activity)
865
                elseif ($this->isType($resourceType, 'RESOURCE_GRADEBOOK', ['gradebook'])) {
866
                    // One snapshot per course/session; treat as a single meta activity.
867
                    $id = 1; // local activity id (opaque; not used by Moodle)
868
                    $title = 'Gradebook';
869
870
                    $activities[] = [
871
                        'id'         => $id,
872
                        'sectionid'  => 0, // place in "General" topic (informational only)
873
                        'modulename' => 'gradebook',
874
                        'moduleid'   => self::GRADEBOOK_MODULE_ID,
875
                        'title'      => $title,
876
                        '__from'     => 'gradebook',
877
                    ];
878
                    @error_log('[MoodleExport::getActivities] ADD (Chamilo-only) gradebook moduleid=' . self::GRADEBOOK_MODULE_ID);
879
                }
880
881
                // Emit activity if resolved
882
                if ($exportClass && $moduleName) {
883
                    /** @var object $exportInstance */
884
                    $exportInstance = new $exportClass($this->course);
885
                    $sectionId = $exportInstance->getSectionIdForActivity($id, $resourceType);
886
                    $activities[] = [
887
                        'id' => $id,
888
                        'sectionid' => $sectionId,
889
                        'modulename' => $moduleName,
890
                        'moduleid' => $id,
891
                        'title' => $title,
892
                    ];
893
                    @error_log('[MoodleExport::getActivities] ADD modulename='.$moduleName.' moduleid='.$id.' sectionid='.$sectionId.' title="'.str_replace(["\n","\r"],' ',$title).'"');
894
                }
895
            }
896
        }
897
898
        // ---- Append synthetic News forum from Chamilo announcements (if any) ----
899
        try {
900
            $res = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
901
            $annBag =
902
                ($res[\defined('RESOURCE_ANNOUNCEMENT') ? RESOURCE_ANNOUNCEMENT : 'announcements'] ?? null)
903
                ?? ($res['announcements'] ?? null)
904
                ?? ($res['announcement'] ?? null)
905
                ?? [];
906
907
            if (!empty($annBag) && !$this->hasAnnouncementsLikeForum($activities)) {
908
                $activities[] = [
909
                    'id'         => 1, // local activity id for our synthetic forum
910
                    'sectionid'  => 0, // place in "General" topic
911
                    'modulename' => 'forum',
912
                    'moduleid'   => self::ANNOUNCEMENTS_MODULE_ID,
913
                    'title'      => get_lang('Announcements'),
914
                    '__from'     => 'announcements',
915
                ];
916
                // Forum contains posts, mark userinfo = true
917
                self::flagActivityUserinfo('forum', self::ANNOUNCEMENTS_MODULE_ID, true);
918
                @error_log('[MoodleExport::getActivities] Added synthetic News forum (announcements) moduleid='.self::ANNOUNCEMENTS_MODULE_ID);
919
            }
920
        } catch (\Throwable $e) {
921
            @error_log('[MoodleExport::getActivities][WARN] announcements detection: '.$e->getMessage());
922
        }
923
924
        @error_log('[MoodleExport::getActivities] Done. total='.count($activities));
925
        return $activities;
926
    }
927
928
    /**
929
     * Collect Moodle URL activities from legacy "link" bucket.
930
     *
931
     * It is defensive against different wrappers:
932
     * - Accepts link objects as $wrap->obj or directly as $wrap.
933
     * - Resolves title from title|name|url (last-resort).
934
     * - Maps category_id to a section name (category title) if available.
935
     *
936
     * @return UrlExport[]
937
     */
938
    private function buildUrlActivities(): array
939
    {
940
        $res = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
941
942
        // Buckets (defensive: allow legacy casings)
943
        $links = $res['link'] ?? $res['Link'] ?? [];
944
        $cats  = $res['link_category'] ?? $res['Link_Category'] ?? [];
945
946
        // Map category_id → label for section naming
947
        $catLabel = [];
948
        foreach ($cats as $cid => $cwrap) {
949
            if (!\is_object($cwrap)) {
950
                continue;
951
            }
952
            $c = (isset($cwrap->obj) && \is_object($cwrap->obj)) ? $cwrap->obj : $cwrap;
953
            $label = '';
954
            foreach (['title', 'name'] as $k) {
955
                if (!empty($c->{$k}) && \is_string($c->{$k})) {
956
                    $label = trim((string) $c->{$k});
957
                    break;
958
                }
959
            }
960
            $catLabel[(int) $cid] = $label !== '' ? $label : ('Category #'.(int) $cid);
961
        }
962
963
        $out = [];
964
        foreach ($links as $id => $lwrap) {
965
            if (!\is_object($lwrap)) {
966
                continue;
967
            }
968
            $L = (isset($lwrap->obj) && \is_object($lwrap->obj)) ? $lwrap->obj : $lwrap;
969
970
            $url = (string) ($L->url ?? '');
971
            if ($url === '') {
972
                // Skip invalid URL records
973
                continue;
974
            }
975
976
            // Resolve a robust title
977
            $title = '';
978
            foreach (['title', 'name'] as $k) {
979
                if (!empty($L->{$k}) && \is_string($L->{$k})) {
980
                    $title = trim((string) $L->{$k});
981
                    break;
982
                }
983
            }
984
            if ($title === '') {
985
                $title = $url; // last resort: use the URL itself
986
            }
987
988
            $target = (string) ($L->target ?? '');
989
            $intro  = (string) ($L->description ?? '');
990
            $cid    = (int) ($L->category_id ?? 0);
991
992
            $sectionName = $catLabel[$cid] ?? null;
993
994
            // UrlExport ctor: (string $title, string $url, ?string $section = null, ?string $introHtml = null, ?string $target = null)
995
            $urlAct = new UrlExport($title, $url, $sectionName ?: null, $intro ?: null, $target ?: null);
996
            if (method_exists($urlAct, 'setLegacyId')) {
997
                $urlAct->setLegacyId((int) $id);
998
            }
999
1000
            $out[] = $urlAct;
1001
        }
1002
1003
        return $out;
1004
    }
1005
1006
    /**
1007
     * Enqueue all URL activities into the export pipeline.
1008
     * Will try queueActivity(), then addActivity(), then $this->activities[].
1009
     */
1010
    private function enqueueUrlActivities(): void
1011
    {
1012
        $urls = $this->buildUrlActivities();
1013
1014
        if (empty($urls)) {
1015
            @error_log('[MoodleExport] No URL activities to enqueue');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1015
            /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport] No URL activities to enqueue');

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1016
            return;
1017
        }
1018
1019
        if (method_exists($this, 'queueActivity')) {
1020
            foreach ($urls as $act) {
1021
                $this->queueActivity($act);
1022
            }
1023
            @error_log('[MoodleExport] URL activities enqueued via queueActivity(): '.count($urls));
1024
            return;
1025
        }
1026
1027
        if (method_exists($this, 'addActivity')) {
1028
            foreach ($urls as $act) {
1029
                $this->addActivity($act);
1030
            }
1031
            @error_log('[MoodleExport] URL activities appended via addActivity(): '.count($urls));
1032
            return;
1033
        }
1034
1035
        if (property_exists($this, 'activities') && \is_array($this->activities)) {
1036
            array_push($this->activities, ...$urls);
1037
            @error_log('[MoodleExport] URL activities appended to $this->activities: '.count($urls));
1038
            return;
1039
        }
1040
1041
        @error_log('[MoodleExport][WARN] Could not enqueue URL activities (no compatible method found)');
1042
    }
1043
1044
    private function exportSections(string $exportDir): void
1045
    {
1046
        $sections = $this->getSections();
1047
        foreach ($sections as $section) {
1048
            $sectionExport = new SectionExport($this->course);
1049
            $sectionExport->exportSection($section['id'], $exportDir);
1050
        }
1051
    }
1052
1053
    private function createMbzFile(string $sourceDir): string
1054
    {
1055
        $zip = new ZipArchive();
1056
        $zipFile = $sourceDir.'.mbz';
1057
1058
        if (true !== $zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
1059
            throw new Exception(get_lang('Error creating zip file'));
1060
        }
1061
1062
        $files = new RecursiveIteratorIterator(
1063
            new RecursiveDirectoryIterator($sourceDir),
1064
            RecursiveIteratorIterator::LEAVES_ONLY
1065
        );
1066
1067
        foreach ($files as $file) {
1068
            if (!$file->isDir()) {
1069
                $filePath = $file->getRealPath();
1070
                $relativePath = substr($filePath, \strlen($sourceDir) + 1);
1071
1072
                if (!$zip->addFile($filePath, $relativePath)) {
1073
                    throw new Exception(get_lang('Error adding file to zip').": $relativePath");
1074
                }
1075
            }
1076
        }
1077
1078
        if (!$zip->close()) {
1079
            throw new Exception(get_lang('Error closing zip file'));
1080
        }
1081
1082
        return $zipFile;
1083
    }
1084
1085
    private function cleanupTempDir(string $dir): void
1086
    {
1087
        $this->recursiveDelete($dir);
1088
    }
1089
1090
    private function recursiveDelete(string $dir): void
1091
    {
1092
        $files = array_diff(scandir($dir), ['.', '..']);
1093
        foreach ($files as $file) {
1094
            $path = "$dir/$file";
1095
            is_dir($path) ? $this->recursiveDelete($path) : unlink($path);
1096
        }
1097
        rmdir($dir);
1098
    }
1099
1100
    /**
1101
     * Export Gradebook metadata into chamilo/gradebook/*.json (no Moodle module).
1102
     * Keeps getActivities() side-effect free and avoids adding to moodle_backup.xml.
1103
     */
1104
    private function exportGradebookActivities(array $activities, string $exportDir): void
1105
    {
1106
        $count = 0;
1107
        foreach ($activities as $a) {
1108
            if (($a['modulename'] ?? '') !== 'gradebook') {
1109
                continue;
1110
            }
1111
            $activityId = (int) ($a['id'] ?? 0); // local/opaque; not strictly needed
1112
            $moduleId   = (int) ($a['moduleid'] ?? self::GRADEBOOK_MODULE_ID);
1113
            $sectionId  = (int) ($a['sectionid'] ?? 0);
1114
1115
            try {
1116
                $meta = new GradebookMetaExport($this->course);
1117
                $meta->export($activityId, $exportDir, $moduleId, $sectionId);
1118
1119
                // No userinfo here (change if you later add per-user grades)
1120
                self::flagActivityUserinfo('gradebook', $moduleId, false);
1121
                $count++;
1122
            } catch (\Throwable $e) {
1123
                @error_log('[MoodleExport::exportGradebookActivities][ERROR] '.$e->getMessage());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1123
                /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportGradebookActivities][ERROR] '.$e->getMessage());

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1124
            }
1125
        }
1126
1127
        @error_log('[MoodleExport::exportGradebookActivities] exported=' . $count);
1128
    }
1129
1130
    /**
1131
     * Export raw learnpath metadata (categories + each LP with items) as JSON sidecars.
1132
     * This does not affect Moodle XML; it complements the backup with Chamilo-native data.
1133
     */
1134
    private function exportLearnpathMeta(string $exportDir): void
1135
    {
1136
        try {
1137
            $meta = new LearnpathMetaExport($this->course);
1138
            $count = $meta->exportAll($exportDir);
1139
            @error_log('[MoodleExport::exportLearnpathMeta] exported learnpaths='.$count);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1139
            /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportLearnpathMeta] exported learnpaths='.$count);

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1140
        } catch (\Throwable $e) {
1141
            @error_log('[MoodleExport::exportLearnpathMeta][ERROR] '.$e->getMessage());
1142
        }
1143
    }
1144
1145
    /**
1146
     * Export quiz raw JSON sidecars (quiz.json, questions.json, answers.json)
1147
     * for every selected quiz activity. This does not affect Moodle XML export.
1148
     */
1149
    private function exportQuizMetaActivities(array $activities, string $exportDir): void
1150
    {
1151
        $count = 0;
1152
        foreach ($activities as $a) {
1153
            if (($a['modulename'] ?? '') !== 'quiz') {
1154
                continue;
1155
            }
1156
            $activityId = (int) ($a['id'] ?? 0);
1157
            $moduleId   = (int) ($a['moduleid'] ?? 0);
1158
            $sectionId  = (int) ($a['sectionid'] ?? 0);
1159
            if ($activityId <= 0 || $moduleId <= 0) {
1160
                continue;
1161
            }
1162
1163
            try {
1164
                $meta = new QuizMetaExport($this->course);
1165
                $meta->export($activityId, $exportDir, $moduleId, $sectionId);
1166
                $count++;
1167
            } catch (\Throwable $e) {
1168
                @error_log('[MoodleExport::exportQuizMetaActivities][ERROR] '.$e->getMessage());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1168
                /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportQuizMetaActivities][ERROR] '.$e->getMessage());

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1169
            }
1170
        }
1171
        @error_log('[MoodleExport::exportQuizMetaActivities] exported='.$count);
1172
    }
1173
1174
    /**
1175
     * Export Attendance metadata into chamilo/attendance/*.json (no Moodle module).
1176
     * Keeps getActivities() side-effect free and avoids adding to moodle_backup.xml.
1177
     */
1178
    private function exportAttendanceActivities(array $activities, string $exportDir): void
1179
    {
1180
        $count = 0;
1181
        foreach ($activities as $a) {
1182
            if (($a['modulename'] ?? '') !== 'attendance') {
1183
                continue;
1184
            }
1185
            $activityId = (int) ($a['id'] ?? 0);
1186
            $moduleId   = (int) ($a['moduleid'] ?? 0);
1187
            $sectionId  = (int) ($a['sectionid'] ?? 0);
1188
            if ($activityId <= 0 || $moduleId <= 0) {
1189
                continue;
1190
            }
1191
1192
            try {
1193
                $meta = new AttendanceMetaExport($this->course);
1194
                $meta->export($activityId, $exportDir, $moduleId, $sectionId);
1195
1196
                // No userinfo here (change to true if you later include per-user marks)
1197
                self::flagActivityUserinfo('attendance', $moduleId, false);
1198
                $count++;
1199
            } catch (\Throwable $e) {
1200
                @error_log('[MoodleExport::exportAttendanceActivities][ERROR] '.$e->getMessage());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1200
                /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportAttendanceActivities][ERROR] '.$e->getMessage());

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1201
            }
1202
        }
1203
1204
        @error_log('[MoodleExport::exportAttendanceActivities] exported='. $count);
1205
    }
1206
1207
    /**
1208
     * Export Label activities into activities/label_{id}/label.xml
1209
     * Only for real "label" items (course descriptions).
1210
     */
1211
    private function exportLabelActivities(array $activities, string $exportDir): void
1212
    {
1213
        foreach ($activities as $a) {
1214
            if (($a['modulename'] ?? '') !== 'label') {
1215
                continue;
1216
            }
1217
            $activityId = (int) ($a['id'] ?? 0);
1218
            $moduleId   = (int) ($a['moduleid'] ?? 0);
1219
            $sectionId  = (int) ($a['sectionid'] ?? 0);
1220
1221
            try {
1222
                $label = new LabelExport($this->course);
1223
                $label->export($activityId, $exportDir, $moduleId, $sectionId);
1224
                @error_log('[MoodleExport::exportLabelActivities] exported label moduleid='.$moduleId.' sectionid='.$sectionId);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1224
                /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportLabelActivities] exported label moduleid='.$moduleId.' sectionid='.$sectionId);

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1225
            } catch (\Throwable $e) {
1226
                @error_log('[MoodleExport::exportLabelActivities][ERROR] '.$e->getMessage());
1227
            }
1228
        }
1229
    }
1230
1231
    private function exportThematicActivities(array $activities, string $exportDir): void
1232
    {
1233
        $count = 0;
1234
        foreach ($activities as $a) {
1235
            if (($a['modulename'] ?? '') !== 'thematic') continue;
1236
1237
            $activityId = (int) ($a['id'] ?? 0);
1238
            $moduleId   = (int) ($a['moduleid'] ?? 0);
1239
            $sectionId  = (int) ($a['sectionid'] ?? 0);
1240
            if ($activityId <= 0 || $moduleId <= 0) continue;
1241
1242
            try {
1243
                $meta = new ThematicMetaExport($this->course);
1244
                $meta->export($activityId, $exportDir, $moduleId, $sectionId);
1245
1246
                // no userinfo for meta-only artifacts
1247
                self::flagActivityUserinfo('thematic', $moduleId, false);
1248
1249
                $count++;
1250
            } catch (\Throwable $e) {
1251
                @error_log('[MoodleExport::exportThematicActivities][ERROR] '.$e->getMessage());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1251
                /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportThematicActivities][ERROR] '.$e->getMessage());

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1252
            }
1253
        }
1254
        @error_log('[MoodleExport::exportThematicActivities] exported='.$count);
1255
    }
1256
1257
    private function exportWikiActivities(array $activities, string $exportDir): void
1258
    {
1259
        foreach ($activities as $a) {
1260
            if (($a['modulename'] ?? '') !== 'wiki') {
1261
                continue;
1262
            }
1263
            $activityId = (int)($a['id'] ?? 0);
1264
            $moduleId   = (int)($a['moduleid'] ?? 0);
1265
            $sectionId  = (int)($a['sectionid'] ?? 0);
1266
            if ($activityId <= 0 || $moduleId <= 0) {
1267
                continue;
1268
            }
1269
            try {
1270
                $exp = new WikiExport($this->course);
1271
                $exp->export($activityId, $exportDir, $moduleId, $sectionId);
1272
                @error_log('[MoodleExport::exportWikiActivities] exported wiki moduleid='.$moduleId.' sectionid='.$sectionId);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1272
                /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportWikiActivities] exported wiki moduleid='.$moduleId.' sectionid='.$sectionId);

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1273
            } catch (\Throwable $e) {
1274
                @error_log('[MoodleExport::exportWikiActivities][ERROR] '.$e->getMessage());
1275
            }
1276
        }
1277
    }
1278
1279
    /**
1280
     * Export synthetic News forum built from Chamilo announcements.
1281
     */
1282
    private function exportAnnouncementsForum(array $activities, string $exportDir): void
1283
    {
1284
        foreach ($activities as $a) {
1285
            if (($a['modulename'] ?? '') !== 'forum') {
1286
                continue;
1287
            }
1288
            if (($a['__from'] ?? '') !== 'announcements') {
1289
                continue; // only our synthetic forum
1290
            }
1291
1292
            $activityId = (int) ($a['id'] ?? 0);
1293
            $moduleId   = (int) ($a['moduleid'] ?? 0);
1294
            $sectionId  = (int) ($a['sectionid'] ?? 0);
1295
1296
            try {
1297
                $exp = new AnnouncementsForumExport($this->course);
1298
                $exp->export($activityId, $exportDir, $moduleId, $sectionId);
1299
                @error_log('[MoodleExport::exportAnnouncementsForum] exported forum moduleid='.$moduleId.' sectionid='.$sectionId);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1299
                /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportAnnouncementsForum] exported forum moduleid='.$moduleId.' sectionid='.$sectionId);

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1300
            } catch (\Throwable $e) {
1301
                @error_log('[MoodleExport::exportAnnouncementsForum][ERROR] '.$e->getMessage());
1302
            }
1303
        }
1304
    }
1305
1306
    /**
1307
     * Export course-level calendar events to course/calendarevents.xml
1308
     * (This is NOT an activity; it belongs to the course folder.)
1309
     */
1310
    private function exportCourseCalendar(string $exportDir): void
1311
    {
1312
        try {
1313
            $cal = new CourseCalendarExport($this->course);
1314
            $count = $cal->export($exportDir);
1315
1316
            // Root backup settings already include "calendarevents" = 1 in createMoodleBackupXml().
1317
            @error_log('[MoodleExport::exportCourseCalendar] exported events='.$count);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1317
            /** @scrutinizer ignore-unhandled */ @error_log('[MoodleExport::exportCourseCalendar] exported events='.$count);

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1318
        } catch (\Throwable $e) {
1319
            @error_log('[MoodleExport::exportCourseCalendar][ERROR] '.$e->getMessage());
1320
        }
1321
    }
1322
1323
    private function exportBadgesXml(string $exportDir): void
1324
    {
1325
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1326
        $xmlContent .= '<badges>'.PHP_EOL;
1327
        $xmlContent .= '</badges>';
1328
        file_put_contents($exportDir.'/badges.xml', $xmlContent);
1329
    }
1330
1331
    private function exportCompletionXml(string $exportDir): void
1332
    {
1333
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1334
        $xmlContent .= '<completions>'.PHP_EOL;
1335
        $xmlContent .= '</completions>';
1336
        file_put_contents($exportDir.'/completion.xml', $xmlContent);
1337
    }
1338
1339
    private function exportGradebookXml(string $exportDir): void
1340
    {
1341
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1342
        $xmlContent .= '<gradebook>'.PHP_EOL;
1343
        $xmlContent .= '</gradebook>';
1344
        file_put_contents($exportDir.'/gradebook.xml', $xmlContent);
1345
    }
1346
1347
    private function exportGradeHistoryXml(string $exportDir): void
1348
    {
1349
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1350
        $xmlContent .= '<grade_history>'.PHP_EOL;
1351
        $xmlContent .= '</grade_history>';
1352
        file_put_contents($exportDir.'/grade_history.xml', $xmlContent);
1353
    }
1354
1355
    private function exportGroupsXml(string $exportDir): void
1356
    {
1357
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1358
        $xmlContent .= '<groups>'.PHP_EOL;
1359
        $xmlContent .= '</groups>';
1360
        file_put_contents($exportDir.'/groups.xml', $xmlContent);
1361
    }
1362
1363
    private function exportOutcomesXml(string $exportDir): void
1364
    {
1365
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1366
        $xmlContent .= '<outcomes>'.PHP_EOL;
1367
        $xmlContent .= '</outcomes>';
1368
        file_put_contents($exportDir.'/outcomes.xml', $xmlContent);
1369
    }
1370
1371
    private function exportRolesXml(string $exportDir): void
1372
    {
1373
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1374
        $xmlContent .= '<roles_definition>'.PHP_EOL;
1375
        $xmlContent .= '  <role id="5">'.PHP_EOL;
1376
        $xmlContent .= '    <name></name>'.PHP_EOL;
1377
        $xmlContent .= '    <shortname>student</shortname>'.PHP_EOL;
1378
        $xmlContent .= '    <nameincourse>$@NULL@$</nameincourse>'.PHP_EOL;
1379
        $xmlContent .= '    <description></description>'.PHP_EOL;
1380
        $xmlContent .= '    <sortorder>5</sortorder>'.PHP_EOL;
1381
        $xmlContent .= '    <archetype>student</archetype>'.PHP_EOL;
1382
        $xmlContent .= '  </role>'.PHP_EOL;
1383
        $xmlContent .= '</roles_definition>'.PHP_EOL;
1384
1385
        file_put_contents($exportDir.'/roles.xml', $xmlContent);
1386
    }
1387
1388
    private function exportScalesXml(string $exportDir): void
1389
    {
1390
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1391
        $xmlContent .= '<scales>'.PHP_EOL;
1392
        $xmlContent .= '</scales>';
1393
        file_put_contents($exportDir.'/scales.xml', $xmlContent);
1394
    }
1395
1396
    private function exportUsersXml(string $exportDir): void
1397
    {
1398
        $adminData = self::getAdminUserData();
1399
1400
        $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
1401
        $xmlContent .= '<users>'.PHP_EOL;
1402
        $xmlContent .= '  <user id="'.$adminData['id'].'" contextid="'.$adminData['contextid'].'">'.PHP_EOL;
1403
        $xmlContent .= '    <username>'.$adminData['username'].'</username>'.PHP_EOL;
1404
        $xmlContent .= '    <idnumber>'.$adminData['idnumber'].'</idnumber>'.PHP_EOL;
1405
        $xmlContent .= '    <email>'.$adminData['email'].'</email>'.PHP_EOL;
1406
        $xmlContent .= '    <phone1>'.$adminData['phone1'].'</phone1>'.PHP_EOL;
1407
        $xmlContent .= '    <phone2>'.$adminData['phone2'].'</phone2>'.PHP_EOL;
1408
        $xmlContent .= '    <institution>'.$adminData['institution'].'</institution>'.PHP_EOL;
1409
        $xmlContent .= '    <department>'.$adminData['department'].'</department>'.PHP_EOL;
1410
        $xmlContent .= '    <address>'.$adminData['address'].'</address>'.PHP_EOL;
1411
        $xmlContent .= '    <city>'.$adminData['city'].'</city>'.PHP_EOL;
1412
        $xmlContent .= '    <country>'.$adminData['country'].'</country>'.PHP_EOL;
1413
        $xmlContent .= '    <lastip>'.$adminData['lastip'].'</lastip>'.PHP_EOL;
1414
        $xmlContent .= '    <picture>'.$adminData['picture'].'</picture>'.PHP_EOL;
1415
        $xmlContent .= '    <description>'.$adminData['description'].'</description>'.PHP_EOL;
1416
        $xmlContent .= '    <descriptionformat>'.$adminData['descriptionformat'].'</descriptionformat>'.PHP_EOL;
1417
        $xmlContent .= '    <imagealt>'.$adminData['imagealt'].'</imagealt>'.PHP_EOL;
1418
        $xmlContent .= '    <auth>'.$adminData['auth'].'</auth>'.PHP_EOL;
1419
        $xmlContent .= '    <firstname>'.$adminData['firstname'].'</firstname>'.PHP_EOL;
1420
        $xmlContent .= '    <lastname>'.$adminData['lastname'].'</lastname>'.PHP_EOL;
1421
        $xmlContent .= '    <confirmed>'.$adminData['confirmed'].'</confirmed>'.PHP_EOL;
1422
        $xmlContent .= '    <policyagreed>'.$adminData['policyagreed'].'</policyagreed>'.PHP_EOL;
1423
        $xmlContent .= '    <deleted>'.$adminData['deleted'].'</deleted>'.PHP_EOL;
1424
        $xmlContent .= '    <lang>'.$adminData['lang'].'</lang>'.PHP_EOL;
1425
        $xmlContent .= '    <theme>'.$adminData['theme'].'</theme>'.PHP_EOL;
1426
        $xmlContent .= '    <timezone>'.$adminData['timezone'].'</timezone>'.PHP_EOL;
1427
        $xmlContent .= '    <firstaccess>'.$adminData['firstaccess'].'</firstaccess>'.PHP_EOL;
1428
        $xmlContent .= '    <lastaccess>'.$adminData['lastaccess'].'</lastaccess>'.PHP_EOL;
1429
        $xmlContent .= '    <lastlogin>'.$adminData['lastlogin'].'</lastlogin>'.PHP_EOL;
1430
        $xmlContent .= '    <currentlogin>'.$adminData['currentlogin'].'</currentlogin>'.PHP_EOL;
1431
        $xmlContent .= '    <mailformat>'.$adminData['mailformat'].'</mailformat>'.PHP_EOL;
1432
        $xmlContent .= '    <maildigest>'.$adminData['maildigest'].'</maildigest>'.PHP_EOL;
1433
        $xmlContent .= '    <maildisplay>'.$adminData['maildisplay'].'</maildisplay>'.PHP_EOL;
1434
        $xmlContent .= '    <autosubscribe>'.$adminData['autosubscribe'].'</autosubscribe>'.PHP_EOL;
1435
        $xmlContent .= '    <trackforums>'.$adminData['trackforums'].'</trackforums>'.PHP_EOL;
1436
        $xmlContent .= '    <timecreated>'.$adminData['timecreated'].'</timecreated>'.PHP_EOL;
1437
        $xmlContent .= '    <timemodified>'.$adminData['timemodified'].'</timemodified>'.PHP_EOL;
1438
        $xmlContent .= '    <trustbitmask>'.$adminData['trustbitmask'].'</trustbitmask>'.PHP_EOL;
1439
1440
        if (isset($adminData['preferences']) && \is_array($adminData['preferences'])) {
1441
            $xmlContent .= '    <preferences>'.PHP_EOL;
1442
            foreach ($adminData['preferences'] as $preference) {
1443
                $xmlContent .= '      <preference>'.PHP_EOL;
1444
                $xmlContent .= '        <name>'.htmlspecialchars((string) $preference['name']).'</name>'.PHP_EOL;
1445
                $xmlContent .= '        <value>'.htmlspecialchars((string) $preference['value']).'</value>'.PHP_EOL;
1446
                $xmlContent .= '      </preference>'.PHP_EOL;
1447
            }
1448
            $xmlContent .= '    </preferences>'.PHP_EOL;
1449
        } else {
1450
            $xmlContent .= '    <preferences></preferences>'.PHP_EOL;
1451
        }
1452
1453
        $xmlContent .= '    <roles>'.PHP_EOL;
1454
        $xmlContent .= '      <role_overrides></role_overrides>'.PHP_EOL;
1455
        $xmlContent .= '      <role_assignments></role_assignments>'.PHP_EOL;
1456
        $xmlContent .= '    </roles>'.PHP_EOL;
1457
1458
        $xmlContent .= '  </user>'.PHP_EOL;
1459
        $xmlContent .= '</users>';
1460
1461
        file_put_contents($exportDir.'/users.xml', $xmlContent);
1462
    }
1463
1464
    private function exportBackupSettings(array $sections, array $activities): array
1465
    {
1466
        $settings = [
1467
            ['level' => 'root', 'name' => 'filename', 'value' => 'backup-moodle-course-'.time().'.mbz'],
1468
            ['level' => 'root', 'name' => 'imscc11', 'value' => '0'],
1469
            ['level' => 'root', 'name' => 'users', 'value' => '1'],
1470
            ['level' => 'root', 'name' => 'anonymize', 'value' => '0'],
1471
            ['level' => 'root', 'name' => 'role_assignments', 'value' => '1'],
1472
            ['level' => 'root', 'name' => 'activities', 'value' => '1'],
1473
            ['level' => 'root', 'name' => 'blocks', 'value' => '1'],
1474
            ['level' => 'root', 'name' => 'files', 'value' => '1'],
1475
            ['level' => 'root', 'name' => 'filters', 'value' => '1'],
1476
            ['level' => 'root', 'name' => 'comments', 'value' => '1'],
1477
            ['level' => 'root', 'name' => 'badges', 'value' => '1'],
1478
            ['level' => 'root', 'name' => 'calendarevents', 'value' => '1'],
1479
            ['level' => 'root', 'name' => 'userscompletion', 'value' => '1'],
1480
            ['level' => 'root', 'name' => 'logs', 'value' => '0'],
1481
            ['level' => 'root', 'name' => 'grade_histories', 'value' => '0'],
1482
            ['level' => 'root', 'name' => 'questionbank', 'value' => '1'],
1483
            ['level' => 'root', 'name' => 'groups', 'value' => '1'],
1484
            ['level' => 'root', 'name' => 'competencies', 'value' => '0'],
1485
            ['level' => 'root', 'name' => 'customfield', 'value' => '1'],
1486
            ['level' => 'root', 'name' => 'contentbankcontent', 'value' => '1'],
1487
            ['level' => 'root', 'name' => 'legacyfiles', 'value' => '1'],
1488
        ];
1489
1490
        foreach ($sections as $section) {
1491
            $settings[] = [
1492
                'level' => 'section',
1493
                'section' => 'section_'.$section['id'],
1494
                'name' => 'section_'.$section['id'].'_included',
1495
                'value' => '1',
1496
            ];
1497
            $settings[] = [
1498
                'level' => 'section',
1499
                'section' => 'section_'.$section['id'],
1500
                'name' => 'section_'.$section['id'].'_userinfo',
1501
                'value' => '1',
1502
            ];
1503
        }
1504
1505
        foreach ($activities as $activity) {
1506
            $settings[] = [
1507
                'level' => 'activity',
1508
                'activity' => $activity['modulename'].'_'.$activity['moduleid'],
1509
                'name' => $activity['modulename'].'_'.$activity['moduleid'].'_included',
1510
                'value' => '1',
1511
            ];
1512
            $value = (self::$activityUserinfo[$activity['modulename']][$activity['moduleid']] ?? false) ? '1' : '0';
1513
            $settings[] = [
1514
                'level' => 'activity',
1515
                'activity' => $activity['modulename'].'_'.$activity['moduleid'],
1516
                'name' => $activity['modulename'].'_'.$activity['moduleid'].'_userinfo',
1517
                'value' => $value,
1518
            ];
1519
        }
1520
1521
        return $settings;
1522
    }
1523
1524
    /** Returns true if an existing forum already looks like "Announcements/News". */
1525
    private function hasAnnouncementsLikeForum(array $activities): bool
1526
    {
1527
        foreach ($activities as $a) {
1528
            if (($a['modulename'] ?? '') !== 'forum') {
1529
                continue;
1530
            }
1531
            $t = mb_strtolower((string) ($a['title'] ?? ''));
1532
            foreach (['announcements','news'] as $kw) {
1533
                if ($t === $kw || str_contains($t, $kw)) {
1534
                    return true; // looks like an announcements/news forum already
1535
                }
1536
            }
1537
        }
1538
        return false;
1539
    }
1540
}
1541