CourseMaintenanceController   F
last analyzed

Complexity

Total Complexity 867

Size/Duplication

Total Lines 3968
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2291
dl 0
loc 3968
rs 0.8
c 1
b 0
f 0
wmc 867

61 Methods

Rating   Name   Duplication   Size   Complexity  
A zipHasMoodleBackupXml() 0 15 3
C importDiagnose() 0 101 13
A loadMoodleCourseOrFail() 0 21 6
A zipHasCourseInfoDat() 0 22 5
A findBucket() 0 5 4
F hydrateLpDependenciesFromSnapshot() 0 116 53
A resolveBackupPath() 0 15 4
F buildCc13Preview() 0 61 17
A isMoodleByExt() 0 5 1
B sanitizePhpGraph() 0 24 7
A getImportSource() 0 9 2
A normalizeSelectedTools() 0 31 4
B deincomplete() 0 26 7
F normalizeBucketsForRestorer() 0 133 23
A findBucketKey() 0 5 2
F loadLegacyCourseForAnyBackup() 0 95 20
F decodeCourseInfo() 0 193 34
B intersectBucketByIds() 0 13 7
B filterCourseResources() 0 52 8
C expandCc13SelectionFromCategories() 0 43 16
D bucketKeyCandidates() 0 33 10
C readCourseInfoFromZip() 0 88 14
F inferToolsFromSelection() 0 55 25
D buildLinkTreeForVue() 0 98 18
A cc13ExportDownload() 0 22 4
A snapshotForumCounts() 0 10 3
A isSelectableItem() 0 7 2
B cc13Import() 0 49 6
D resolveItemLabel() 0 130 37
C snapshotResources() 0 32 13
A importOptions() 0 14 1
F buildResourceTreeForVue() 0 277 73
F buildForumTreeForVue() 0 256 68
B copyExecute() 0 72 9
A moodleExportOptions() 0 31 1
C deleteCourse() 0 71 13
B importUpload() 0 39 7
A cc13ExportOptions() 0 9 1
A mapSameNameOption() 0 18 5
A firstExistingKey() 0 9 5
A recycleOptions() 0 12 1
A setDebugFromRequest() 0 16 6
A copyOptions() 0 23 3
A copyResources() 0 43 3
A importResources() 0 29 3
B moodleExportResources() 0 77 8
B recycleExecute() 0 44 8
A getSkipTypeKeys() 0 15 1
F cc13ExportExecute() 0 103 17
A objectEntity() 0 7 3
A getDefaultTypeTitles() 0 28 1
F moodleExportExecute() 0 118 28
B cc13ExportResources() 0 42 9
A logDebug() 0 23 6
B importServerPick() 0 23 8
F importRestore() 0 202 40
D buildExtra() 0 67 21
A recycleResources() 0 18 2
A normalizeTypeKey() 0 31 2
B buildSelectionFromTypes() 0 54 11
F filterLegacyCourseBySelection() 0 361 135

How to fix   Complexity   

Complex Class

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

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

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

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use __PHP_Incomplete_Class;
10
use Chamilo\CoreBundle\Repository\Node\UserRepository;
11
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Capabilities;
12
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Export;
13
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Imscc13Import;
14
use Chamilo\CourseBundle\Component\CourseCopy\Course;
15
use Chamilo\CourseBundle\Component\CourseCopy\CourseArchiver;
16
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
17
use Chamilo\CourseBundle\Component\CourseCopy\CourseRecycler;
18
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
19
use Chamilo\CourseBundle\Component\CourseCopy\CourseSelectForm;
20
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleExport;
21
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleImport;
22
use CourseManager;
23
use Doctrine\ORM\EntityManagerInterface;
24
use RuntimeException;
25
use stdClass;
26
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
27
use Symfony\Component\HttpFoundation\{BinaryFileResponse, JsonResponse, Request, ResponseHeaderBag};
28
use Symfony\Component\Routing\Attribute\Route;
29
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
30
use Symfony\Component\Security\Http\Attribute\IsGranted;
31
use Throwable;
32
use UnserializeApi;
33
use ZipArchive;
34
35
use const ARRAY_FILTER_USE_BOTH;
36
use const DIRECTORY_SEPARATOR;
37
use const FILTER_VALIDATE_BOOL;
38
use const JSON_PARTIAL_OUTPUT_ON_ERROR;
39
use const JSON_UNESCAPED_SLASHES;
40
use const JSON_UNESCAPED_UNICODE;
41
use const PATHINFO_EXTENSION;
42
43
#[IsGranted('ROLE_TEACHER')]
44
#[Route('/course_maintenance/{node}', name: 'cm_', requirements: ['node' => '\d+'])]
45
class CourseMaintenanceController extends AbstractController
46
{
47
    /**
48
     * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1
49
     */
50
    private bool $debug = true;
51
52
    #[Route('/import/options', name: 'import_options', methods: ['GET'])]
53
    public function importOptions(int $node, Request $req): JsonResponse
54
    {
55
        $this->setDebugFromRequest($req);
56
        $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]);
57
58
        return $this->json([
59
            'sources' => ['local', 'server'],
60
            'importOptions' => ['full_backup', 'select_items'],
61
            'sameName' => ['skip', 'rename', 'overwrite'],
62
            'defaults' => [
63
                'importOption' => 'full_backup',
64
                'sameName' => 'rename',
65
                'sameFileNameOption' => 2,
66
            ],
67
        ]);
68
    }
69
70
    #[Route('/import/upload', name: 'import_upload', methods: ['POST'])]
71
    public function importUpload(int $node, Request $req): JsonResponse
72
    {
73
        $this->setDebugFromRequest($req);
74
75
        $file = $req->files->get('file');
76
        if (!$file || !$file->isValid()) {
77
            return $this->json(['error' => 'Invalid upload'], 400);
78
        }
79
80
        $maxBytes = 1024 * 1024 * 512;
81
        if ($file->getSize() > $maxBytes) {
82
            return $this->json(['error' => 'File too large'], 413);
83
        }
84
85
        $allowed = ['zip', 'mbz', 'gz', 'tgz'];
86
        $ext = strtolower($file->guessExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
87
        if (!\in_array($ext, $allowed, true)) {
88
            return $this->json(['error' => 'Unsupported file type'], 415);
89
        }
90
91
        $this->logDebug('[importUpload] received', [
92
            'original_name' => $file->getClientOriginalName(),
93
            'size' => $file->getSize(),
94
            'mime' => $file->getClientMimeType(),
95
        ]);
96
97
        $backupId = CourseArchiver::importUploadedFile($file->getRealPath());
98
        if (false === $backupId) {
99
            $this->logDebug('[importUpload] archive dir not writable');
100
101
            return $this->json(['error' => 'Archive directory is not writable'], 500);
102
        }
103
104
        $this->logDebug('[importUpload] stored', ['backupId' => $backupId]);
105
106
        return $this->json([
107
            'backupId' => $backupId,
108
            'filename' => $file->getClientOriginalName(),
109
        ]);
110
    }
111
112
    #[Route('/import/server', name: 'import_server_pick', methods: ['POST'])]
113
    public function importServerPick(int $node, Request $req): JsonResponse
114
    {
115
        $this->setDebugFromRequest($req);
116
        $payload = json_decode($req->getContent() ?: '{}', true);
117
118
        $filename = basename((string) ($payload['filename'] ?? ''));
119
        if ('' === $filename || preg_match('/[\/\\\]/', $filename)) {
120
            return $this->json(['error' => 'Invalid filename'], 400);
121
        }
122
123
        $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename;
124
        $realBase = realpath(CourseArchiver::getBackupDir());
125
        $realPath = realpath($path);
126
        if (!$realBase || !$realPath || 0 !== strncmp($realBase, $realPath, \strlen($realBase)) || !is_file($realPath)) {
127
            $this->logDebug('[importServerPick] file not found or outside base', ['path' => $path]);
128
129
            return $this->json(['error' => 'File not found'], 404);
130
        }
131
132
        $this->logDebug('[importServerPick] ok', ['backupId' => $filename]);
133
134
        return $this->json(['backupId' => $filename, 'filename' => $filename]);
135
    }
136
137
    #[Route(
138
        '/import/{backupId}/resources',
139
        name: 'import_resources',
140
        requirements: ['backupId' => '.+'],
141
        methods: ['GET']
142
    )]
143
    public function importResources(int $node, string $backupId, Request $req): JsonResponse
144
    {
145
        $this->setDebugFromRequest($req);
146
        $mode = strtolower((string) $req->query->get('mode', 'auto')); // 'auto' | 'dat' | 'moodle'
147
148
        $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode);
149
150
        $this->logDebug('[importResources] course loaded', [
151
            'has_resources' => \is_array($course->resources ?? null),
152
            'keys' => array_keys((array) ($course->resources ?? [])),
153
        ]);
154
155
        $tree = $this->buildResourceTreeForVue($course);
156
157
        $warnings = [];
158
        if (empty($tree)) {
159
            $warnings[] = 'Backup has no selectable resources.';
160
        }
161
162
        return $this->json([
163
            'tree' => $tree,
164
            'warnings' => $warnings,
165
            'meta' => ['import_source' => $course->resources['__meta']['import_source'] ?? null],
166
        ]);
167
    }
168
169
    #[Route(
170
        '/import/{backupId}/restore',
171
        name: 'import_restore',
172
        requirements: ['backupId' => '.+'],
173
        methods: ['POST']
174
    )]
175
    public function importRestore(
176
        int $node,
177
        string $backupId,
178
        Request $req,
179
        EntityManagerInterface $em
180
    ): JsonResponse {
181
        $this->setDebugFromRequest($req);
182
        $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]);
183
184
        try {
185
            $payload = json_decode($req->getContent() ?: '{}', true);
186
            // Keep mode consistent with GET /import/{backupId}/resources
187
            $mode = strtolower((string) ($payload['mode'] ?? 'auto'));
188
189
            $importOption = (string) ($payload['importOption'] ?? 'full_backup');
190
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
191
192
            /** @var array<string,array> $selectedResources */
193
            $selectedResources = (array) ($payload['resources'] ?? []);
194
195
            /** @var string[] $selectedTypes */
196
            $selectedTypes = array_map('strval', (array) ($payload['selectedTypes'] ?? []));
197
198
            $this->logDebug('[importRestore] input', [
199
                'importOption' => $importOption,
200
                'sameFileNameOption' => $sameFileNameOption,
201
                'selectedTypes' => $selectedTypes,
202
                'hasResourcesMap' => !empty($selectedResources),
203
                'mode' => $mode,
204
            ]);
205
206
            // Load with same mode to avoid switching source on POST
207
            $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode);
208
            if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
209
                return $this->json(['error' => 'Backup has no resources'], 400);
210
            }
211
212
            $resourcesAll = $course->resources;
213
            $this->logDebug('[importRestore] BEFORE filter keys', array_keys($resourcesAll));
214
215
            // Always hydrate LP dependencies (even in full_backup).
216
            $this->hydrateLpDependenciesFromSnapshot($course, $resourcesAll);
217
            $this->logDebug('[importRestore] AFTER hydrate keys', array_keys((array) $course->resources));
218
219
            // Detect source BEFORE any filtering (meta may be dropped by filters)
220
            $importSource = $this->getImportSource($course);
221
            $isMoodle = ('moodle' === $importSource);
222
            $this->logDebug('[importRestore] detected import source', ['import_source' => $importSource, 'isMoodle' => $isMoodle]);
223
224
            if ('select_items' === $importOption) {
225
                if (empty($selectedResources) && !empty($selectedTypes)) {
226
                    $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes);
227
                }
228
229
                $hasAny = false;
230
                foreach ($selectedResources as $ids) {
231
                    if (\is_array($ids) && !empty($ids)) {
232
                        $hasAny = true;
233
234
                        break;
235
                    }
236
                }
237
                if (!$hasAny) {
238
                    return $this->json(['error' => 'No resources selected'], 400);
239
                }
240
241
                $course = $this->filterLegacyCourseBySelection($course, $selectedResources);
242
                if (empty($course->resources) || 0 === \count((array) $course->resources)) {
243
                    return $this->json(['error' => 'Selection produced no resources to restore'], 400);
244
                }
245
            }
246
247
            $this->logDebug('[importRestore] AFTER filter keys', array_keys((array) $course->resources));
248
249
            // NON-MOODLE
250
            if (!$isMoodle) {
251
                $this->logDebug('[importRestore] non-Moodle backup -> using CourseRestorer');
252
                // Trace around normalization to detect bucket drops
253
                $this->logDebug('[importRestore] BEFORE normalize', array_keys((array) $course->resources));
254
255
                $this->normalizeBucketsForRestorer($course);
256
                $this->logDebug('[importRestore] AFTER  normalize', array_keys((array) $course->resources));
257
258
                $restorer = new CourseRestorer($course);
259
                $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
260
                if (method_exists($restorer, 'setResourcesAllSnapshot')) {
261
                    $restorer->setResourcesAllSnapshot($resourcesAll);
262
                }
263
                if (method_exists($restorer, 'setDebug')) {
264
                    $restorer->setDebug($this->debug);
265
                }
266
                $restorer->restore();
267
268
                CourseArchiver::cleanBackupDir();
269
270
                $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0);
271
                $redirectUrl = \sprintf('/course/%d/home?sid=0&gid=0', $courseId);
272
273
                return $this->json([
274
                    'ok' => true,
275
                    'message' => 'Import finished',
276
                    'redirectUrl' => $redirectUrl,
277
                ]);
278
            }
279
280
            // MOODLE
281
            $this->logDebug('[importRestore] Moodle backup -> using MoodleImport.*');
282
283
            $backupPath = $this->resolveBackupPath($backupId);
284
            $ci = api_get_course_info();
285
            $cid = (int) ($ci['real_id'] ?? 0);
286
            $sid = 0;
287
288
            $presentBuckets = array_map('strtolower', array_keys((array) $course->resources));
289
            $present = static fn (string $k): bool => \in_array(strtolower($k), $presentBuckets, true);
290
291
            $wantedGroups = [];
292
            $mark = static function (array &$dst, bool $cond, string $key): void { if ($cond) { $dst[$key] = true; } };
293
294
            if ('full_backup' === $importOption) {
295
                // Be tolerant with plural 'documents'
296
                $mark($wantedGroups, $present('link') || $present('link_category'), 'links');
297
                $mark($wantedGroups, $present('forum') || $present('forum_category'), 'forums');
298
                $mark($wantedGroups, $present('document') || $present('documents'), 'documents');
299
                $mark($wantedGroups, $present('quiz') || $present('exercise'), 'quizzes');
300
                $mark($wantedGroups, $present('scorm'), 'scorm');
301
            } else {
302
                $mark($wantedGroups, $present('link'), 'links');
303
                $mark($wantedGroups, $present('forum') || $present('forum_category'), 'forums');
304
                $mark($wantedGroups, $present('document') || $present('documents'), 'documents');
305
                $mark($wantedGroups, $present('quiz') || $present('exercise'), 'quizzes');
306
                $mark($wantedGroups, $present('scorm'), 'scorm');
307
            }
308
309
            if (empty($wantedGroups)) {
310
                CourseArchiver::cleanBackupDir();
311
312
                return $this->json([
313
                    'ok' => true,
314
                    'message' => 'Nothing to import for Moodle (no supported resource groups present)',
315
                    'stats' => new stdClass(),
316
                ]);
317
            }
318
319
            $importer = new MoodleImport(debug: $this->debug);
320
            $stats = [];
321
322
            // LINKS
323
            if (!empty($wantedGroups['links']) && method_exists($importer, 'restoreLinks')) {
324
                $stats['links'] = $importer->restoreLinks($backupPath, $em, $cid, $sid, $course);
325
            }
326
327
            // FORUMS
328
            if (!empty($wantedGroups['forums']) && method_exists($importer, 'restoreForums')) {
329
                $stats['forums'] = $importer->restoreForums($backupPath, $em, $cid, $sid, $course);
330
            }
331
332
            // DOCUMENTS
333
            if (!empty($wantedGroups['documents']) && method_exists($importer, 'restoreDocuments')) {
334
                $stats['documents'] = $importer->restoreDocuments(
335
                    $backupPath,
336
                    $em,
337
                    $cid,
338
                    $sid,
339
                    $sameFileNameOption,
340
                    $course
341
                );
342
            }
343
344
            // QUIZZES
345
            if (!empty($wantedGroups['quizzes']) && method_exists($importer, 'restoreQuizzes')) {
346
                $stats['quizzes'] = $importer->restoreQuizzes($backupPath, $em, $cid, $sid);
347
            }
348
349
            // SCORM
350
            if (!empty($wantedGroups['scorm']) && method_exists($importer, 'restoreScorm')) {
351
                $stats['scorm'] = $importer->restoreScorm($backupPath, $em, $cid, $sid);
352
            }
353
354
            CourseArchiver::cleanBackupDir();
355
356
            return $this->json([
357
                'ok' => true,
358
                'message' => 'Moodle import finished',
359
                'stats' => $stats,
360
            ]);
361
        } catch (Throwable $e) {
362
            $this->logDebug('[importRestore] exception', [
363
                'message' => $e->getMessage(),
364
                'file' => $e->getFile().':'.$e->getLine(),
365
            ]);
366
367
            return $this->json([
368
                'error' => 'Restore failed: '.$e->getMessage(),
369
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
370
            ], 500);
371
        }
372
    }
373
374
    #[Route('/copy/options', name: 'copy_options', methods: ['GET'])]
375
    public function copyOptions(int $node, Request $req): JsonResponse
376
    {
377
        $this->setDebugFromRequest($req);
378
379
        $current = api_get_course_info();
380
        $courseList = CourseManager::getCoursesFollowedByUser(api_get_user_id());
381
382
        $courses = [];
383
        foreach ($courseList as $c) {
384
            if ((int) $c['real_id'] === (int) $current['real_id']) {
385
                continue;
386
            }
387
            $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']];
388
        }
389
390
        return $this->json([
391
            'courses' => $courses,
392
            'defaults' => [
393
                'copyOption' => 'full_copy',
394
                'includeUsers' => false,
395
                'resetDates' => true,
396
                'sameFileNameOption' => 2,
397
            ],
398
        ]);
399
    }
400
401
    #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])]
402
    public function copyResources(int $node, Request $req): JsonResponse
403
    {
404
        $this->setDebugFromRequest($req);
405
        $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', ''));
406
        if ('' === $sourceCourseCode) {
407
            return $this->json(['error' => 'Missing sourceCourseId'], 400);
408
        }
409
410
        $cb = new CourseBuilder();
411
        $cb->set_tools_to_build([
412
            'documents',
413
            'forums',
414
            'tool_intro',
415
            'links',
416
            'quizzes',
417
            'quiz_questions',
418
            'assets',
419
            'surveys',
420
            'survey_questions',
421
            'announcements',
422
            'events',
423
            'course_descriptions',
424
            'glossary',
425
            'wiki',
426
            'thematic',
427
            'attendance',
428
            'works',
429
            'gradebook',
430
            'learnpath_category',
431
            'learnpaths',
432
        ]);
433
434
        $course = $cb->build(0, $sourceCourseCode);
435
436
        $tree = $this->buildResourceTreeForVue($course);
437
438
        $warnings = [];
439
        if (empty($tree)) {
440
            $warnings[] = 'Source course has no resources.';
441
        }
442
443
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
444
    }
445
446
    #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])]
447
    public function copyExecute(int $node, Request $req): JsonResponse
448
    {
449
        $this->setDebugFromRequest($req);
450
451
        try {
452
            $payload = json_decode($req->getContent() ?: '{}', true);
453
454
            $sourceCourseId = (string) ($payload['sourceCourseId'] ?? '');
455
            $copyOption = (string) ($payload['copyOption'] ?? 'full_copy');
456
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
457
            $selectedResourcesMap = (array) ($payload['resources'] ?? []);
458
459
            if ('' === $sourceCourseId) {
460
                return $this->json(['error' => 'Missing sourceCourseId'], 400);
461
            }
462
463
            $cb = new CourseBuilder('partial');
464
            $cb->set_tools_to_build([
465
                'documents',
466
                'forums',
467
                'tool_intro',
468
                'links',
469
                'quizzes',
470
                'quiz_questions',
471
                'assets',
472
                'surveys',
473
                'survey_questions',
474
                'announcements',
475
                'events',
476
                'course_descriptions',
477
                'glossary',
478
                'wiki',
479
                'thematic',
480
                'attendance',
481
                'works',
482
                'gradebook',
483
                'learnpath_category',
484
                'learnpaths',
485
            ]);
486
            $legacyCourse = $cb->build(0, $sourceCourseId);
487
488
            if ('select_items' === $copyOption) {
489
                $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap);
490
491
                if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) {
492
                    return $this->json(['error' => 'Selection produced no resources to copy'], 400);
493
                }
494
            }
495
496
            error_log('$legacyCourse :::: '.print_r($legacyCourse, true));
0 ignored issues
show
Bug introduced by
Are you sure print_r($legacyCourse, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

496
            error_log('$legacyCourse :::: './** @scrutinizer ignore-type */ print_r($legacyCourse, true));
Loading history...
497
498
            $restorer = new CourseRestorer($legacyCourse);
499
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
500
            if (method_exists($restorer, 'setDebug')) {
501
                $restorer->setDebug($this->debug);
502
            }
503
            $restorer->restore();
504
505
            $dest = api_get_course_info();
506
            $redirectUrl = \sprintf('/course/%d/home', (int) $dest['real_id']);
507
508
            return $this->json([
509
                'ok' => true,
510
                'message' => 'Copy finished',
511
                'redirectUrl' => $redirectUrl,
512
            ]);
513
        } catch (Throwable $e) {
514
            return $this->json([
515
                'error' => 'Copy failed: '.$e->getMessage(),
516
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
517
            ], 500);
518
        }
519
    }
520
521
    #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])]
522
    public function recycleOptions(int $node, Request $req): JsonResponse
523
    {
524
        $this->setDebugFromRequest($req);
525
526
        // current course only
527
        $defaults = [
528
            'recycleOption' => 'select_items', // 'full_recycle' | 'select_items'
529
            'confirmNeeded' => true,           // show code-confirm input when full
530
        ];
531
532
        return $this->json(['defaults' => $defaults]);
533
    }
534
535
    #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])]
536
    public function recycleResources(int $node, Request $req): JsonResponse
537
    {
538
        $this->setDebugFromRequest($req);
539
540
        // Build legacy Course from CURRENT course (not “source”)
541
        $cb = new CourseBuilder();
542
        $cb->set_tools_to_build([
543
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
544
            'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki',
545
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
546
        ]);
547
        $course = $cb->build(0, api_get_course_id());
548
549
        $tree = $this->buildResourceTreeForVue($course);
550
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
551
552
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
553
    }
554
555
    #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])]
556
    public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse
557
    {
558
        try {
559
            $p = json_decode($req->getContent() ?: '{}', true);
560
            $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items'
561
            $resourcesMap = (array) ($p['resources'] ?? []);
562
            $confirmCode = (string) ($p['confirm'] ?? '');
563
564
            $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items';
565
566
            if ('full_backup' === $type) {
567
                if ($confirmCode !== api_get_course_id()) {
568
                    return $this->json(['error' => 'Course code confirmation mismatch'], 400);
569
                }
570
            } else {
571
                if (empty($resourcesMap)) {
572
                    return $this->json(['error' => 'No resources selected'], 400);
573
                }
574
            }
575
576
            $courseCode = api_get_course_id();
577
            $courseInfo = api_get_course_info($courseCode);
578
            $courseId = (int) ($courseInfo['real_id'] ?? 0);
579
            if ($courseId <= 0) {
580
                return $this->json(['error' => 'Invalid course id'], 400);
581
            }
582
583
            $recycler = new CourseRecycler(
584
                $em,
585
                $courseCode,
586
                $courseId
587
            );
588
589
            $recycler->recycle($type, $resourcesMap);
590
591
            return $this->json([
592
                'ok' => true,
593
                'message' => 'Recycle finished',
594
            ]);
595
        } catch (Throwable $e) {
596
            return $this->json([
597
                'error' => 'Recycle failed: '.$e->getMessage(),
598
            ], 500);
599
        }
600
    }
601
602
    #[Route('/delete', name: 'delete', methods: ['POST'])]
603
    public function deleteCourse(int $node, Request $req): JsonResponse
604
    {
605
        // Basic permission gate (adjust roles to your policy if needed)
606
        if (
607
            !$this->isGranted('ROLE_ADMIN')
608
            && !$this->isGranted('ROLE_TEACHER')
609
            && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
610
        ) {
611
            return $this->json(['error' => 'You are not allowed to delete this course'], 403);
612
        }
613
614
        try {
615
            $payload = json_decode($req->getContent() ?: '{}', true);
616
            $confirm = trim((string) ($payload['confirm'] ?? ''));
617
618
            if ('' === $confirm) {
619
                return $this->json(['error' => 'Missing confirmation value'], 400);
620
            }
621
622
            // Optional flag: also delete orphan documents that belong only to this course
623
            // Accepts 1/0, true/false, "1"/"0"
624
            $deleteDocsRaw = $payload['delete_docs'] ?? 0;
625
            $deleteDocs = filter_var($deleteDocsRaw, FILTER_VALIDATE_BOOL);
626
627
            // Current course
628
            $courseInfo = api_get_course_info();
629
            if (empty($courseInfo)) {
630
                return $this->json(['error' => 'Unable to resolve current course'], 400);
631
            }
632
633
            $officialCode = (string) ($courseInfo['official_code'] ?? '');
634
            $runtimeCode = (string) api_get_course_id(); // often equals official code
635
            $sysCode = (string) ($courseInfo['sysCode'] ?? ''); // used by legacy delete
636
637
            if ('' === $sysCode) {
638
                return $this->json(['error' => 'Invalid course system code'], 400);
639
            }
640
641
            // Accept either official_code or api_get_course_id() as confirmation
642
            $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm);
643
            if (!$matches) {
644
                return $this->json(['error' => 'Course code confirmation mismatch'], 400);
645
            }
646
647
            // Legacy delete (removes course data + unregisters members in this course)
648
            // Now with optional orphan-docs deletion flag.
649
            CourseManager::delete_course($sysCode, $deleteDocs);
650
651
            // Best-effort cleanup of legacy course session flags
652
            try {
653
                $ses = $req->getSession();
654
                $ses?->remove('_cid');
655
                $ses?->remove('_real_cid');
656
            } catch (Throwable $e) {
657
                // swallow — not critical
658
            }
659
660
            // Decide where to send the user afterwards
661
            $redirectUrl = '/index.php';
662
663
            return $this->json([
664
                'ok' => true,
665
                'message' => 'Course deleted successfully',
666
                'redirectUrl' => $redirectUrl,
667
            ]);
668
        } catch (Throwable $e) {
669
            return $this->json([
670
                'error' => 'Failed to delete course: '.$e->getMessage(),
671
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
672
            ], 500);
673
        }
674
    }
675
676
    #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])]
677
    public function moodleExportOptions(int $node, Request $req, UserRepository $users): JsonResponse
678
    {
679
        $defaults = [
680
            'moodleVersion' => '4',
681
            'scope' => 'full',
682
            'admin' => $users->getDefaultAdminForExport(),
683
        ];
684
685
        $tools = [
686
            ['value' => 'documents', 'label' => 'Documents (files & root HTML pages)'],
687
            ['value' => 'links', 'label' => 'Links (URL)'],
688
            ['value' => 'forums', 'label' => 'Forums'],
689
            ['value' => 'quizzes', 'label' => 'Quizzes', 'implies' => ['quiz_questions']],
690
            ['value' => 'surveys', 'label' => 'Surveys', 'implies' => ['survey_questions']],
691
            ['value' => 'works', 'label' => 'Tasks'],
692
            ['value' => 'glossary', 'label' => 'Glossary'],
693
            ['value' => 'learnpaths', 'label' => 'Paths learning'],
694
            ['value' => 'tool_intro', 'label' => 'Course Introduction'],
695
            ['value' => 'course_description', 'label' => 'Course descriptions'],
696
        ];
697
698
        $defaults['tools'] = array_column($tools, 'value');
699
700
        return $this->json([
701
            'versions' => [
702
                ['value' => '3', 'label' => 'Moodle 3.x'],
703
                ['value' => '4', 'label' => 'Moodle 4.x'],
704
            ],
705
            'tools' => $tools,
706
            'defaults' => $defaults,
707
        ]);
708
    }
709
710
    #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])]
711
    public function moodleExportResources(int $node, Request $req): JsonResponse
712
    {
713
        // Enable/disable debug from request
714
        $this->setDebugFromRequest($req);
715
        $this->logDebug('[moodleExportResources] start', ['node' => $node]);
716
717
        try {
718
            // Normalize incoming tools from query (?tools[]=documents&tools[]=links ...)
719
            $selectedTools = $this->normalizeSelectedTools($req->query->all('tools'));
720
721
            // Default toolset tailored for Moodle export picker:
722
            $defaultToolsForMoodle = [
723
                'documents', 'links', 'forums',
724
                'quizzes', 'quiz_questions',
725
                'surveys', 'survey_questions',
726
                'learnpaths', 'learnpath_category',
727
                'works', 'glossary',
728
                'tool_intro',
729
                'course_descriptions',
730
            ];
731
732
            // Use client tools if provided; otherwise our Moodle-safe defaults
733
            $tools = !empty($selectedTools) ? $selectedTools : $defaultToolsForMoodle;
734
735
            // Policy for this endpoint:
736
            //  - Never show gradebook
737
            //  - Always include tool_intro in the picker (harmless if empty)
738
            $tools = array_values(array_diff($tools, ['gradebook']));
739
            if (!\in_array('tool_intro', $tools, true)) {
740
                $tools[] = 'tool_intro';
741
            }
742
743
            $this->logDebug('[moodleExportResources] tools to build', $tools);
744
745
            // Build legacy Course snapshot from CURRENT course (not from a source course)
746
            $cb = new CourseBuilder();
747
            $cb->set_tools_to_build($tools);
748
            $course = $cb->build(0, api_get_course_id());
749
750
            // Build the UI-friendly tree
751
            $tree = $this->buildResourceTreeForVue($course);
752
753
            // Basic warnings for the client
754
            $warnings = empty($tree) ? ['This course has no resources.'] : [];
755
756
            // Some compact debug about the resulting tree
757
            if ($this->debug) {
758
                $this->logDebug(
759
                    '[moodleExportResources] tree summary',
760
                    array_map(
761
                        fn ($g) => [
762
                            'type' => $g['type'] ?? '',
763
                            'title' => $g['title'] ?? '',
764
                            'items' => isset($g['items']) ? \count((array) $g['items']) : null,
765
                            'children' => isset($g['children']) ? \count((array) $g['children']) : null,
766
                        ],
767
                        $tree
768
                    )
769
                );
770
            }
771
772
            return $this->json([
773
                'tree' => $tree,
774
                'warnings' => $warnings,
775
            ]);
776
        } catch (Throwable $e) {
777
            // Defensive error path
778
            $this->logDebug('[moodleExportResources] exception', [
779
                'message' => $e->getMessage(),
780
                'file' => $e->getFile().':'.$e->getLine(),
781
            ]);
782
783
            return $this->json([
784
                'error' => 'Failed to build resource tree for Moodle export.',
785
                'details' => $e->getMessage(),
786
            ], 500);
787
        }
788
    }
789
790
    #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])]
791
    public function moodleExportExecute(int $node, Request $req, UserRepository $users): BinaryFileResponse|JsonResponse
792
    {
793
        $this->setDebugFromRequest($req);
794
795
        $p = json_decode($req->getContent() ?: '{}', true) ?: [];
796
        $moodleVersion = (string) ($p['moodleVersion'] ?? '4');  // "3" | "4"
797
        $scope = (string) ($p['scope'] ?? 'full');       // "full" | "selected"
798
        $adminId = (int) ($p['adminId'] ?? 0);
799
        $adminLogin = trim((string) ($p['adminLogin'] ?? ''));
800
        $adminEmail = trim((string) ($p['adminEmail'] ?? ''));
801
        $selected = \is_array($p['resources'] ?? null) ? (array) $p['resources'] : [];
802
        $toolsInput = \is_array($p['tools'] ?? null) ? (array) $p['tools'] : [];
803
804
        if (!\in_array($moodleVersion, ['3', '4'], true)) {
805
            return $this->json(['error' => 'Unsupported Moodle version'], 400);
806
        }
807
        if ('selected' === $scope && empty($selected)) {
808
            return $this->json(['error' => 'No resources selected'], 400);
809
        }
810
811
        $defaultTools = [
812
            'documents', 'links', 'forums',
813
            'quizzes', 'quiz_questions',
814
            'surveys', 'survey_questions',
815
            'learnpaths', 'learnpath_category',
816
            'works', 'glossary',
817
            'course_descriptions',
818
        ];
819
820
        $tools = $this->normalizeSelectedTools($toolsInput);
821
822
        // If scope=selected, merge inferred tools from selection
823
        if ('selected' === $scope) {
824
            $inferred = $this->inferToolsFromSelection($selected);
825
            $tools = $this->normalizeSelectedTools(array_merge($tools, $inferred));
826
        }
827
828
        // Remove unsupported tools
829
        $tools = array_values(array_unique(array_diff($tools, ['gradebook'])));
830
        $clientSentNoTools = empty($toolsInput);
831
        $useDefault = ('full' === $scope && $clientSentNoTools);
832
        $toolsToBuild = $useDefault ? $defaultTools : $tools;
833
834
        // Ensure "tool_intro" is present (append only if missing)
835
        if (!\in_array('tool_intro', $toolsToBuild, true)) {
836
            $toolsToBuild[] = 'tool_intro';
837
        }
838
839
        // Final dedupe/normalize
840
        $toolsToBuild = array_values(array_unique($toolsToBuild));
841
842
        $this->logDebug('[moodleExportExecute] course tools to build (final)', $toolsToBuild);
843
844
        if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
845
            $adm = $users->getDefaultAdminForExport();
846
            $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
847
            $adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
848
            $adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? '[email protected]');
849
        }
850
851
        $courseId = api_get_course_id();
852
        if (empty($courseId)) {
853
            return $this->json(['error' => 'No active course context'], 400);
854
        }
855
856
        $cb = new CourseBuilder();
857
        $cb->set_tools_to_build($toolsToBuild);
858
        $course = $cb->build(0, $courseId);
859
860
        if ('selected' === $scope) {
861
            $course = $this->filterLegacyCourseBySelection($course, $selected);
862
            if (empty($course->resources) || !\is_array($course->resources)) {
863
                return $this->json(['error' => 'Selection produced no resources to export'], 400);
864
            }
865
        }
866
867
        try {
868
            // === Export to Moodle MBZ ===
869
            $selectionMode = ('selected' === $scope);
870
            $exporter = new MoodleExport($course, $selectionMode);
871
            $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail);
872
873
            $exportDir = 'moodle_export_'.date('Ymd_His');
874
            $versionNum = ('3' === $moodleVersion) ? 3 : 4;
875
876
            $this->logDebug('[moodleExportExecute] starting exporter', [
877
                'courseId' => $courseId,
878
                'exportDir' => $exportDir,
879
                'versionNum' => $versionNum,
880
                'selection' => $selectionMode,
881
                'scope' => $scope,
882
            ]);
883
884
            $mbzPath = $exporter->export($courseId, $exportDir, $versionNum);
885
886
            if (!\is_string($mbzPath) || '' === $mbzPath || !is_file($mbzPath)) {
887
                return $this->json(['error' => 'Moodle export failed: artifact not found'], 500);
888
            }
889
890
            // Build download response
891
            $resp = new BinaryFileResponse($mbzPath);
892
            $resp->setContentDisposition(
893
                ResponseHeaderBag::DISPOSITION_ATTACHMENT,
894
                basename($mbzPath)
895
            );
896
            $resp->headers->set('X-Moodle-Version', (string) $versionNum);
897
            $resp->headers->set('X-Export-Scope', $scope);
898
            $resp->headers->set('X-Selection-Mode', $selectionMode ? '1' : '0');
899
900
            return $resp;
901
        } catch (Throwable $e) {
902
            $this->logDebug('[moodleExportExecute] exception', [
903
                'message' => $e->getMessage(),
904
                'code' => (int) $e->getCode(),
905
            ]);
906
907
            return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
908
        }
909
    }
910
911
    /**
912
     * Normalize tool list to supported ones and add implied dependencies.
913
     *
914
     * @param array<int,string>|null $tools
915
     *
916
     * @return string[]
917
     */
918
    private function normalizeSelectedTools(?array $tools): array
919
    {
920
        // Single list of supported tool buckets (must match CourseBuilder/exporters)
921
        $all = [
922
            'documents', 'links', 'quizzes', 'quiz_questions', 'surveys', 'survey_questions',
923
            'announcements', 'events', 'course_descriptions', 'glossary', 'wiki', 'thematic',
924
            'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', 'tool_intro', 'forums',
925
        ];
926
927
        // Implied dependencies
928
        $deps = [
929
            'quizzes' => ['quiz_questions'],
930
            'surveys' => ['survey_questions'],
931
            'learnpaths' => ['learnpath_category'],
932
        ];
933
934
        $sel = \is_array($tools) ? array_values(array_intersect($tools, $all)) : [];
935
936
        foreach ($sel as $t) {
937
            foreach ($deps[$t] ?? [] as $d) {
938
                $sel[] = $d;
939
            }
940
        }
941
942
        // Unique and preserve a sane order based on $all
943
        $sel = array_values(array_unique($sel));
944
        usort($sel, static function ($a, $b) use ($all) {
945
            return array_search($a, $all, true) <=> array_search($b, $all, true);
946
        });
947
948
        return $sel;
949
    }
950
951
    #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])]
952
    public function cc13ExportOptions(int $node, Request $req): JsonResponse
953
    {
954
        $this->setDebugFromRequest($req);
955
956
        return $this->json([
957
            'defaults' => ['scope' => 'full'],
958
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
959
            'message' => 'Common Cartridge 1.3: documents (webcontent) and links (HTML stub as webcontent). Forums not exported yet.',
960
        ]);
961
    }
962
963
    #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])]
964
    public function cc13ExportResources(int $node, Request $req): JsonResponse
965
    {
966
        $this->setDebugFromRequest($req);
967
968
        $cb = new CourseBuilder();
969
        $cb->set_tools_to_build(['documents', 'links', 'forums']);
970
        $course = $cb->build(0, api_get_course_id());
971
972
        $treeAll = $this->buildResourceTreeForVue($course);
973
        $tree = Cc13Capabilities::filterTree($treeAll);
974
975
        // Count exportables using "items"
976
        $exportableCount = 0;
977
        foreach ($tree as $group) {
978
            if (empty($group['items']) || !\is_array($group['items'])) {
979
                continue;
980
            }
981
982
            if (($group['type'] ?? '') === 'forum') {
983
                foreach ($group['items'] as $cat) {
984
                    foreach (($cat['items'] ?? []) as $forumNode) {
985
                        if (($forumNode['type'] ?? '') === 'forum') {
986
                            $exportableCount++;
987
                        }
988
                    }
989
                }
990
            } else {
991
                $exportableCount += \count($group['items'] ?? []);
992
            }
993
        }
994
995
        $warnings = [];
996
        if (0 === $exportableCount) {
997
            $warnings[] = 'This course has no CC 1.3 exportable resources (documents, links or forums).';
998
        }
999
1000
        return $this->json([
1001
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
1002
            'tree' => $tree,
1003
            'preview' => ['counts' => ['total' => $exportableCount]],
1004
            'warnings' => $warnings,
1005
        ]);
1006
    }
1007
1008
    #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])]
1009
    public function cc13ExportExecute(int $node, Request $req): JsonResponse
1010
    {
1011
        $payload = json_decode((string) $req->getContent(), true) ?: [];
1012
        // If the client sent "resources", treat as selected even if scope says "full".
1013
        $scope = (string) ($payload['scope'] ?? (!empty($payload['resources']) ? 'selected' : 'full'));
1014
        $selected = (array) ($payload['resources'] ?? []);
1015
1016
        // Normalize selection structure (documents/links/forums/…)
1017
        $normSel = Cc13Capabilities::filterSelection($selected);
1018
1019
        // Builder setup
1020
        $tools = ['documents', 'links', 'forums'];
1021
        $cb = new CourseBuilder();
1022
1023
        $selectionMode = false;
1024
1025
        try {
1026
            /** @var Course|null $courseFull */
1027
            $courseFull = null;
1028
1029
            if ('selected' === $scope) {
1030
                // Build a full snapshot first to expand any category-only selections.
1031
                $cbFull = new CourseBuilder();
1032
                $cbFull->set_tools_to_build($tools);
1033
                $courseFull = $cbFull->build(0, api_get_course_id());
1034
1035
                $expanded = $this->expandCc13SelectionFromCategories($courseFull, $normSel);
1036
1037
                // Build per-tool ID map for CourseBuilder
1038
                $map = [];
1039
                if (!empty($expanded['documents'])) {
1040
                    $map['documents'] = array_map('intval', array_keys($expanded['documents']));
1041
                }
1042
                if (!empty($expanded['links'])) {
1043
                    $map['links'] = array_map('intval', array_keys($expanded['links']));
1044
                }
1045
                if (!empty($expanded['forums'])) {
1046
                    $map['forums'] = array_map('intval', array_keys($expanded['forums']));
1047
                }
1048
1049
                if (empty($map)) {
1050
                    return $this->json(['error' => 'Please select at least one resource.'], 400);
1051
                }
1052
1053
                $cb->set_tools_to_build($tools);
1054
                $cb->set_tools_specific_id_list($map);
1055
                $selectionMode = true;
1056
            } else {
1057
                $cb->set_tools_to_build($tools);
1058
            }
1059
1060
            $course = $cb->build(0, api_get_course_id());
1061
1062
            // Safety net: if selection mode, ensure resources are filtered
1063
            if ($selectionMode) {
1064
                // Convert to the expected structure for filterCourseResources()
1065
                $safeSelected = [
1066
                    'documents' => array_fill_keys(array_map('intval', array_keys($normSel['documents'] ?? [])), true),
1067
                    'links' => array_fill_keys(array_map('intval', array_keys($normSel['links'] ?? [])), true),
1068
                    'forums' => array_fill_keys(array_map('intval', array_keys($normSel['forums'] ?? [])), true),
1069
                ];
1070
                // Also include expansions from categories
1071
                $fullSnapshot = $courseFull ?: $course;
1072
                $expandedAll = $this->expandCc13SelectionFromCategories($fullSnapshot, $normSel);
1073
                foreach (['documents', 'links', 'forums'] as $k) {
1074
                    if (!isset($expandedAll[$k])) {
1075
                        continue;
1076
                    }
1077
1078
                    foreach (array_keys($expandedAll[$k]) as $idStr) {
1079
                        $safeSelected[$k][(int) $idStr] = true;
1080
                    }
1081
                }
1082
1083
                $this->filterCourseResources($course, $safeSelected);
1084
                if (empty($course->resources) || !\is_array($course->resources)) {
1085
                    return $this->json(['error' => 'Nothing to export after filtering your selection.'], 400);
1086
                }
1087
            }
1088
1089
            $exporter = new Cc13Export($course, $selectionMode, /* debug */ false);
1090
            $imsccPath = $exporter->export(api_get_course_id());
1091
            $fileName = basename($imsccPath);
1092
1093
            $downloadUrl = $this->generateUrl(
1094
                'cm_cc13_export_download',
1095
                ['node' => $node],
1096
                UrlGeneratorInterface::ABSOLUTE_URL
1097
            ).'?file='.rawurlencode($fileName);
1098
1099
            return $this->json([
1100
                'ok' => true,
1101
                'file' => $fileName,
1102
                'downloadUrl' => $downloadUrl,
1103
                'message' => 'Export finished.',
1104
            ]);
1105
        } catch (RuntimeException $e) {
1106
            if (false !== stripos($e->getMessage(), 'Nothing to export')) {
1107
                return $this->json(['error' => 'Nothing to export (no compatible resources found).'], 400);
1108
            }
1109
1110
            return $this->json(['error' => 'CC 1.3 export failed: '.$e->getMessage()], 500);
1111
        }
1112
    }
1113
1114
    #[Route('/cc13/export/download', name: 'cc13_export_download', methods: ['GET'])]
1115
    public function cc13ExportDownload(int $node, Request $req): BinaryFileResponse|JsonResponse
1116
    {
1117
        // Validate the filename we will serve
1118
        $file = basename((string) $req->query->get('file', ''));
1119
        // Example pattern: ABC123_cc13_20251017_195455.imscc
1120
        if ('' === $file || !preg_match('/^[A-Za-z0-9_-]+_cc13_\d{8}_\d{6}\.imscc$/', $file)) {
1121
            return $this->json(['error' => 'Invalid file'], 400);
1122
        }
1123
1124
        $abs = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$file;
1125
        if (!is_file($abs)) {
1126
            return $this->json(['error' => 'File not found'], 404);
1127
        }
1128
1129
        // Stream file to the browser
1130
        $resp = new BinaryFileResponse($abs);
1131
        $resp->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file);
1132
        // A sensible CC mime; many LMS aceptan zip también
1133
        $resp->headers->set('Content-Type', 'application/vnd.ims.ccv1p3+imscc');
1134
1135
        return $resp;
1136
    }
1137
1138
    #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])]
1139
    public function cc13Import(int $node, Request $req): JsonResponse
1140
    {
1141
        $this->setDebugFromRequest($req);
1142
1143
        try {
1144
            $file = $req->files->get('file');
1145
            if (!$file || !$file->isValid()) {
1146
                return $this->json(['error' => 'Missing or invalid upload.'], 400);
1147
            }
1148
1149
            $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION));
1150
            if (!\in_array($ext, ['imscc', 'zip'], true)) {
1151
                return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 415);
1152
            }
1153
1154
            // Move to a temp file
1155
            $tmpZip = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.
1156
                'cc13_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)).'.'.$ext;
1157
            $file->move(\dirname($tmpZip), basename($tmpZip));
1158
1159
            // Extract
1160
            $extractDir = Imscc13Import::unzip($tmpZip);
1161
1162
            // Detect and validate format
1163
            $format = Imscc13Import::detectFormat($extractDir);
1164
            if (Imscc13Import::FORMAT_IMSCC13 !== $format) {
1165
                Imscc13Import::rrmdir($extractDir);
1166
                @unlink($tmpZip);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). 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

1166
                /** @scrutinizer ignore-unhandled */ @unlink($tmpZip);

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...
1167
1168
                return $this->json(['error' => 'This package is not a Common Cartridge 1.3.'], 400);
1169
            }
1170
1171
            // Execute import (creates Chamilo resources)
1172
            $importer = new Imscc13Import();
1173
            $importer->execute($extractDir);
1174
1175
            // Cleanup
1176
            Imscc13Import::rrmdir($extractDir);
1177
            @unlink($tmpZip);
1178
1179
            return $this->json([
1180
                'ok' => true,
1181
                'message' => 'CC 1.3 import completed successfully.',
1182
            ]);
1183
        } catch (Throwable $e) {
1184
            return $this->json([
1185
                'error' => 'CC 1.3 import failed: '.$e->getMessage(),
1186
            ], 500);
1187
        }
1188
    }
1189
1190
    #[Route(
1191
        '/import/{backupId}/diagnose',
1192
        name: 'import_diagnose',
1193
        requirements: ['backupId' => '.+'],
1194
        methods: ['GET']
1195
    )]
1196
    public function importDiagnose(int $node, string $backupId, Request $req): JsonResponse
1197
    {
1198
        $this->setDebugFromRequest($req);
1199
        $this->logDebug('[importDiagnose] begin', ['node' => $node, 'backupId' => $backupId]);
1200
1201
        try {
1202
            // Resolve absolute path of the uploaded/selected backup
1203
            $path = $this->resolveBackupPath($backupId);
1204
            if (!is_file($path)) {
1205
                return $this->json(['error' => 'Backup file not found', 'path' => $path], 404);
1206
            }
1207
1208
            // Read course_info.dat bytes from ZIP
1209
            $ci = $this->readCourseInfoFromZip($path);
1210
            if (empty($ci['ok'])) {
1211
                $this->logDebug('[importDiagnose] course_info.dat not found or unreadable', $ci);
1212
1213
                return $this->json([
1214
                    'meta' => [
1215
                        'backupId' => $backupId,
1216
                        'path' => $path,
1217
                    ],
1218
                    'zip' => [
1219
                        'error' => $ci['error'] ?? 'unknown error',
1220
                        'zip_list_sample' => $ci['zip_list_sample'] ?? [],
1221
                        'num_files' => $ci['num_files'] ?? null,
1222
                    ],
1223
                ], 200);
1224
            }
1225
1226
            $raw = (string) $ci['data'];
1227
            $size = (int) ($ci['size'] ?? \strlen($raw));
1228
            $md5 = md5($raw);
1229
1230
            // Detect & decode content
1231
            $probe = $this->decodeCourseInfo($raw);
1232
1233
            // Build a tiny scan snapshot (only keys, no grafo)
1234
            $scan = [
1235
                'has_graph' => false,
1236
                'resources_keys' => [],
1237
                'note' => 'No graph parsed',
1238
            ];
1239
1240
            if (!empty($probe['is_serialized']) && isset($probe['value']) && \is_object($probe['value'])) {
1241
                /** @var object $course */
1242
                $course = $probe['value'];
1243
                $scan['has_graph'] = true;
1244
                $scan['resources_keys'] = (isset($course->resources) && \is_array($course->resources))
1245
                    ? array_keys($course->resources)
1246
                    : [];
1247
                $scan['note'] = 'Parsed PHP serialized graph';
1248
            } elseif (!empty($probe['is_json']) && \is_array($probe['json_preview'])) {
1249
                $jp = $probe['json_preview'];
1250
                $scan['has_graph'] = true;
1251
                $scan['resources_keys'] = (isset($jp['resources']) && \is_array($jp['resources']))
1252
                    ? array_keys($jp['resources'])
1253
                    : [];
1254
                $scan['note'] = 'Parsed JSON document';
1255
            }
1256
1257
            $probeOut = $probe;
1258
            unset($probeOut['value'], $probeOut['decoded']);
1259
1260
            $out = [
1261
                'meta' => [
1262
                    'backupId' => $backupId,
1263
                    'path' => $path,
1264
                    'node' => $node,
1265
                ],
1266
                'zip' => [
1267
                    'name' => $ci['name'] ?? null,
1268
                    'index' => $ci['index'] ?? null,
1269
                ],
1270
                'course_info_dat' => [
1271
                    'size_bytes' => $size,
1272
                    'md5' => $md5,
1273
                ],
1274
                'probe' => $probeOut,
1275
                'scan' => $scan,
1276
            ];
1277
1278
            $this->logDebug('[importDiagnose] done', [
1279
                'encoding' => $probeOut['encoding'] ?? null,
1280
                'has_graph' => $scan['has_graph'],
1281
                'resources_keys' => $scan['resources_keys'],
1282
            ]);
1283
1284
            return $this->json($out);
1285
        } catch (Throwable $e) {
1286
            $this->logDebug('[importDiagnose] exception', ['message' => $e->getMessage()]);
1287
1288
            return $this->json([
1289
                'error' => 'Diagnosis failed: '.$e->getMessage(),
1290
            ], 500);
1291
        }
1292
    }
1293
1294
    /**
1295
     * Try to detect and decode course_info.dat content.
1296
     * Hardened: preprocess typed-prop numeric strings and register legacy aliases
1297
     * before attempting unserialize. Falls back to relaxed mode to avoid typed
1298
     * property crashes during diagnosis.
1299
     */
1300
    private function decodeCourseInfo(string $raw): array
1301
    {
1302
        $r = [
1303
            'encoding' => 'raw',
1304
            'decoded_len' => \strlen($raw),
1305
            'magic_hex' => bin2hex(substr($raw, 0, 8)),
1306
            'magic_ascii' => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)),
1307
            'steps' => [],
1308
            'decoded' => null,
1309
            'is_serialized' => false,
1310
            'is_json' => false,
1311
            'json_preview' => null,
1312
        ];
1313
1314
        $isJson = static function (string $s): bool {
1315
            $t = ltrim($s);
1316
1317
            return '' !== $t && ('{' === $t[0] || '[' === $t[0]);
1318
        };
1319
1320
        // Centralized tolerant unserialize with typed-props preprocessing
1321
        $tryUnserializeTolerant = function (string $s, string $label) use (&$r) {
1322
            $ok = false;
1323
            $val = null;
1324
            $err = null;
1325
            $relaxed = false;
1326
1327
            // Ensure legacy aliases and coerce numeric strings before unserialize
1328
            try {
1329
                CourseArchiver::ensureLegacyAliases();
1330
            } catch (Throwable) { /* ignore */
1331
            }
1332
1333
            try {
1334
                $s = CourseArchiver::preprocessSerializedPayloadForTypedProps($s);
1335
            } catch (Throwable) { /* ignore */
1336
            }
1337
1338
            // Strict mode
1339
            set_error_handler(static function (): void {});
1340
1341
            try {
1342
                $val = @unserialize($s, ['allowed_classes' => true]);
1343
                $ok = (false !== $val) || ('b:0;' === trim($s));
1344
            } catch (Throwable $e) {
1345
                $err = $e->getMessage();
1346
                $ok = false;
1347
            } finally {
1348
                restore_error_handler();
1349
            }
1350
            $r['steps'][] = ['action' => "unserialize[$label][strict]", 'ok' => $ok, 'error' => $err];
1351
1352
            // Relaxed fallback (no class instantiation) + deincomplete to stdClass
1353
            if (!$ok) {
1354
                $err2 = null;
1355
                set_error_handler(static function (): void {});
1356
1357
                try {
1358
                    $tmp = @unserialize($s, ['allowed_classes' => false]);
1359
                    if (false !== $tmp || 'b:0;' === trim($s)) {
1360
                        $val = $this->deincomplete($tmp);
1361
                        $ok = true;
1362
                        $relaxed = true;
1363
                        $err = null;
1364
                    }
1365
                } catch (Throwable $e2) {
1366
                    $err2 = $e2->getMessage();
1367
                } finally {
1368
                    restore_error_handler();
1369
                }
1370
                $r['steps'][] = ['action' => "unserialize[$label][relaxed]", 'ok' => $ok, 'error' => $err2];
1371
            }
1372
1373
            if ($ok) {
1374
                $r['is_serialized'] = true;
1375
                $r['decoded'] = null; // keep payload minimal
1376
                $r['used_relaxed'] = $relaxed;
1377
1378
                return $val;
1379
            }
1380
1381
            return null;
1382
        };
1383
1384
        // 0) JSON as-is?
1385
        if ($isJson($raw)) {
1386
            $r['encoding'] = 'json';
1387
            $r['is_json'] = true;
1388
            $r['json_preview'] = json_decode($raw, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1389
1390
            return $r;
1391
        }
1392
1393
        // Direct PHP serialize (strict then relaxed, after preprocessing)
1394
        if (($u = $tryUnserializeTolerant($raw, 'raw')) !== null) {
1395
            $r['encoding'] = 'php-serialize';
1396
1397
            return $r + ['value' => $u];
1398
        }
1399
1400
        // GZIP
1401
        if (0 === strncmp($raw, "\x1F\x8B", 2)) {
1402
            $dec = @gzdecode($raw);
1403
            $r['steps'][] = ['action' => 'gzdecode', 'ok' => false !== $dec];
1404
            if (false !== $dec) {
1405
                if ($isJson($dec)) {
1406
                    $r['encoding'] = 'gzip+json';
1407
                    $r['is_json'] = true;
1408
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1409
1410
                    return $r;
1411
                }
1412
                if (($u = $tryUnserializeTolerant($dec, 'gzip')) !== null) {
1413
                    $r['encoding'] = 'gzip+php-serialize';
1414
1415
                    return $r + ['value' => $u];
1416
                }
1417
            }
1418
        }
1419
1420
        // ZLIB/DEFLATE
1421
        $z2 = substr($raw, 0, 2);
1422
        if ("\x78\x9C" === $z2 || "\x78\xDA" === $z2) {
1423
            $dec = @gzuncompress($raw);
1424
            $r['steps'][] = ['action' => 'gzuncompress', 'ok' => false !== $dec];
1425
            if (false !== $dec) {
1426
                if ($isJson($dec)) {
1427
                    $r['encoding'] = 'zlib+json';
1428
                    $r['is_json'] = true;
1429
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1430
1431
                    return $r;
1432
                }
1433
                if (($u = $tryUnserializeTolerant($dec, 'zlib')) !== null) {
1434
                    $r['encoding'] = 'zlib+php-serialize';
1435
1436
                    return $r + ['value' => $u];
1437
                }
1438
            }
1439
            $dec2 = @gzinflate($raw);
1440
            $r['steps'][] = ['action' => 'gzinflate', 'ok' => false !== $dec2];
1441
            if (false !== $dec2) {
1442
                if ($isJson($dec2)) {
1443
                    $r['encoding'] = 'deflate+json';
1444
                    $r['is_json'] = true;
1445
                    $r['json_preview'] = json_decode($dec2, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1446
1447
                    return $r;
1448
                }
1449
                if (($u = $tryUnserializeTolerant($dec2, 'deflate')) !== null) {
1450
                    $r['encoding'] = 'deflate+php-serialize';
1451
1452
                    return $r + ['value' => $u];
1453
                }
1454
            }
1455
        }
1456
1457
        // BASE64 (e.g. "Tzo0ODoi..." -> base64('O:48:"Chamilo...'))
1458
        if (preg_match('~^[A-Za-z0-9+/=\r\n]+$~', $raw)) {
1459
            $dec = base64_decode($raw, true);
1460
            $r['steps'][] = ['action' => 'base64_decode', 'ok' => false !== $dec];
1461
            if (false !== $dec) {
1462
                if ($isJson($dec)) {
1463
                    $r['encoding'] = 'base64(json)';
1464
                    $r['is_json'] = true;
1465
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1466
1467
                    return $r;
1468
                }
1469
                if (($u = $tryUnserializeTolerant($dec, 'base64')) !== null) {
1470
                    $r['encoding'] = 'base64(php-serialize)';
1471
1472
                    return $r + ['value' => $u];
1473
                }
1474
                // base64 + gzip nested
1475
                if (0 === strncmp($dec, "\x1F\x8B", 2)) {
1476
                    $dec2 = @gzdecode($dec);
1477
                    $r['steps'][] = ['action' => 'base64+gzdecode', 'ok' => false !== $dec2];
1478
                    if (false !== $dec2 && ($u = $tryUnserializeTolerant($dec2, 'base64+gzip')) !== null) {
1479
                        $r['encoding'] = 'base64(gzip+php-serialize)';
1480
1481
                        return $r + ['value' => $u];
1482
                    }
1483
                }
1484
            }
1485
        }
1486
1487
        // Nested ZIP?
1488
        if (0 === strncmp($raw, "PK\x03\x04", 4)) {
1489
            $r['encoding'] = 'nested-zip';
1490
        }
1491
1492
        return $r;
1493
    }
1494
1495
    /**
1496
     * Replace any __PHP_Incomplete_Class instances with stdClass (deep).
1497
     * Also traverses arrays and objects (diagnostics-only).
1498
     */
1499
    private function deincomplete(mixed $v): mixed
1500
    {
1501
        if ($v instanceof __PHP_Incomplete_Class) {
1502
            $o = new stdClass();
1503
            foreach (get_object_vars($v) as $k => $vv) {
1504
                $o->{$k} = $this->deincomplete($vv);
1505
            }
1506
1507
            return $o;
1508
        }
1509
        if (\is_array($v)) {
1510
            foreach ($v as $k => $vv) {
1511
                $v[$k] = $this->deincomplete($vv);
1512
            }
1513
1514
            return $v;
1515
        }
1516
        if (\is_object($v)) {
1517
            foreach (get_object_vars($v) as $k => $vv) {
1518
                $v->{$k} = $this->deincomplete($vv);
1519
            }
1520
1521
            return $v;
1522
        }
1523
1524
        return $v;
1525
    }
1526
1527
    /**
1528
     * Return [ok, name, index, size, data] for the first matching entry of course_info.dat (case-insensitive).
1529
     * Also tries common subpaths, e.g., "course/course_info.dat".
1530
     */
1531
    private function readCourseInfoFromZip(string $zipPath): array
1532
    {
1533
        $candidates = [
1534
            'course_info.dat',
1535
            'course/course_info.dat',
1536
            'backup/course_info.dat',
1537
        ];
1538
1539
        $zip = new ZipArchive();
1540
        if (true !== ($err = $zip->open($zipPath))) {
1541
            return ['ok' => false, 'error' => 'Failed to open ZIP (ZipArchive::open error '.$err.')'];
1542
        }
1543
1544
        // First: direct name lookup (case-insensitive)
1545
        $foundIdx = null;
1546
        $foundName = null;
1547
1548
        for ($i = 0; $i < $zip->numFiles; $i++) {
1549
            $st = $zip->statIndex($i);
1550
            if (!$st || !isset($st['name'])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $st 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...
1551
                continue;
1552
            }
1553
            $name = (string) $st['name'];
1554
            $base = strtolower(basename($name));
1555
            if ('course_info.dat' === $base) {
1556
                $foundIdx = $i;
1557
                $foundName = $name;
1558
1559
                break;
1560
            }
1561
        }
1562
1563
        // Try specific candidate paths if direct scan failed
1564
        if (null === $foundIdx) {
1565
            foreach ($candidates as $cand) {
1566
                $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
1567
                if (false !== $idx) {
1568
                    $foundIdx = $idx;
1569
                    $foundName = $zip->getNameIndex($idx);
1570
1571
                    break;
1572
                }
1573
            }
1574
        }
1575
1576
        if (null === $foundIdx) {
1577
            // Build a short listing for debugging
1578
            $list = [];
1579
            $limit = min($zip->numFiles, 200);
1580
            for ($i = 0; $i < $limit; $i++) {
1581
                $n = $zip->getNameIndex($i);
1582
                if (false !== $n) {
1583
                    $list[] = $n;
1584
                }
1585
            }
1586
            $zip->close();
1587
1588
            return [
1589
                'ok' => false,
1590
                'error' => 'course_info.dat not found in archive',
1591
                'zip_list_sample' => $list,
1592
                'num_files' => $zip->numFiles,
1593
            ];
1594
        }
1595
1596
        $stat = $zip->statIndex($foundIdx);
1597
        $size = (int) ($stat['size'] ?? 0);
1598
        $fp = $zip->getStream($foundName);
1599
        if (!$fp) {
0 ignored issues
show
introduced by
$fp is of type resource, thus it always evaluated to false.
Loading history...
1600
            $zip->close();
1601
1602
            return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)'];
1603
        }
1604
1605
        $data = stream_get_contents($fp);
1606
        fclose($fp);
1607
        $zip->close();
1608
1609
        if (!\is_string($data)) {
1610
            return ['ok' => false, 'error' => 'Failed to read course_info.dat contents'];
1611
        }
1612
1613
        return [
1614
            'ok' => true,
1615
            'name' => $foundName,
1616
            'index' => $foundIdx,
1617
            'size' => $size,
1618
            'data' => $data,
1619
        ];
1620
    }
1621
1622
    /**
1623
     * Copies the dependencies (document, link, quiz, etc.) to $course->resources
1624
     * that reference the selected LearnPaths, taking the items from the full snapshot.
1625
     *
1626
     * It doesn't break anything if something is missing or comes in a different format: it's defensive.
1627
     */
1628
    private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void
1629
    {
1630
        if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) {
1631
            return;
1632
        }
1633
1634
        $depTypes = [
1635
            'document', 'link', 'quiz', 'work', 'survey',
1636
            'Forum_Category', 'forum', 'thread', 'post',
1637
            'Exercise_Question', 'survey_question', 'Link_Category',
1638
        ];
1639
1640
        $need = [];
1641
        $addNeed = function (string $type, $id) use (&$need): void {
1642
            $t = (string) $type;
1643
            $i = is_numeric($id) ? (int) $id : (string) $id;
1644
            if ('' === $i || 0 === $i) {
1645
                return;
1646
            }
1647
            $need[$t] ??= [];
1648
            $need[$t][$i] = true;
1649
        };
1650
1651
        foreach ($course->resources['learnpath'] as $lpId => $lpWrap) {
1652
            $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap;
1653
1654
            if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) {
1655
                foreach ($lpWrap->linked_resources as $t => $ids) {
1656
                    if (!\is_array($ids)) {
1657
                        continue;
1658
                    }
1659
                    foreach ($ids as $rid) {
1660
                        $addNeed($t, $rid);
1661
                    }
1662
                }
1663
            }
1664
1665
            $items = [];
1666
            if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) {
1667
                $items = $lp->items;
1668
            } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) {
1669
                $items = $lpWrap->items;
1670
            }
1671
1672
            foreach ($items as $it) {
1673
                $ito = \is_object($it) ? $it : (object) $it;
1674
1675
                if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) {
1676
                    foreach ($ito->linked_resources as $t => $ids) {
1677
                        if (!\is_array($ids)) {
1678
                            continue;
1679
                        }
1680
                        foreach ($ids as $rid) {
1681
                            $addNeed($t, $rid);
1682
                        }
1683
                    }
1684
                }
1685
1686
                foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) {
1687
                    if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) {
1688
                        $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field};
1689
                        $t = $typeGuess ?: (string) ($ito->type ?? '');
1690
                        if ('' !== $t) {
1691
                            $addNeed($t, $rid);
1692
                        }
1693
                    }
1694
                }
1695
1696
                if (!empty($ito->type) && isset($ito->ref)) {
1697
                    $addNeed((string) $ito->type, $ito->ref);
1698
                }
1699
            }
1700
        }
1701
1702
        if (empty($need)) {
1703
            $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'];
1704
            foreach ($core as $k) {
1705
                if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) {
1706
                    $course->resources[$k] ??= [];
1707
                    if (0 === \count($course->resources[$k])) {
1708
                        $course->resources[$k] = $snapshot[$k];
1709
                    }
1710
                }
1711
            }
1712
            $this->logDebug('[LP-deps] fallback filled from snapshot', [
1713
                'bags' => array_keys(array_filter($course->resources, fn ($v, $k) => \in_array($k, $core, true) && \is_array($v) && \count($v) > 0, ARRAY_FILTER_USE_BOTH)),
1714
            ]);
1715
1716
            return;
1717
        }
1718
1719
        foreach ($need as $type => $idMap) {
1720
            if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) {
1721
                continue;
1722
            }
1723
1724
            $course->resources[$type] ??= [];
1725
1726
            foreach (array_keys($idMap) as $rid) {
1727
                $src = $snapshot[$type][$rid]
1728
                    ?? $snapshot[$type][(string) $rid]
1729
                    ?? null;
1730
1731
                if (!$src) {
1732
                    continue;
1733
                }
1734
1735
                if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) {
1736
                    $course->resources[$type][$rid] = $src;
1737
                }
1738
            }
1739
        }
1740
1741
        $this->logDebug('[LP-deps] hydrated', [
1742
            'types' => array_keys($need),
1743
            'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)),
1744
        ]);
1745
    }
1746
1747
    /**
1748
     * Build a Vue-friendly tree from legacy Course.
1749
     */
1750
    private function buildResourceTreeForVue(object $course): array
1751
    {
1752
        if ($this->debug) {
1753
            $this->logDebug('[buildResourceTreeForVue] start');
1754
        }
1755
1756
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
1757
            ? $course->resources
1758
            : [];
1759
1760
        $legacyTitles = [];
1761
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
1762
            /** @var array<string,string> $legacyTitles */
1763
            $legacyTitles = CourseSelectForm::getResourceTitleList();
1764
        }
1765
        $fallbackTitles = $this->getDefaultTypeTitles();
1766
        $skipTypes = $this->getSkipTypeKeys();
1767
1768
        $tree = [];
1769
1770
        if (!empty($resources['document']) && \is_array($resources['document'])) {
1771
            $docs = $resources['document'];
1772
1773
            $normalize = function (string $rawPath, string $title, string $filetype): string {
1774
                $p = trim($rawPath, '/');
1775
                $p = (string) preg_replace('~^(?:document/)+~i', '', $p);
1776
                $parts = array_values(array_filter(explode('/', $p), 'strlen'));
1777
1778
                // host
1779
                if (!empty($parts) && ('localhost' === $parts[0] || str_contains($parts[0], '.'))) {
1780
                    array_shift($parts);
1781
                }
1782
                // course-code
1783
                if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) {
1784
                    array_shift($parts);
1785
                }
1786
1787
                $clean = implode('/', $parts);
1788
                if ('' === $clean && 'folder' !== $filetype) {
1789
                    $clean = $title;
1790
                }
1791
                if ('folder' === $filetype) {
1792
                    $clean = rtrim($clean, '/').'/';
1793
                }
1794
1795
                return $clean;
1796
            };
1797
1798
            $folderIdByPath = [];
1799
            foreach ($docs as $obj) {
1800
                if (!\is_object($obj)) {
1801
                    continue;
1802
                }
1803
                $ft = (string) ($obj->filetype ?? $obj->file_type ?? '');
1804
                if ('folder' !== $ft) {
1805
                    continue;
1806
                }
1807
                $rel = $normalize((string) $obj->path, (string) $obj->title, $ft);
1808
                $key = rtrim($rel, '/');
1809
                if ('' !== $key) {
1810
                    $folderIdByPath[strtolower($key)] = (int) $obj->source_id;
1811
                }
1812
            }
1813
1814
            $docRoot = [];
1815
            $findChild = static function (array &$children, string $label): ?int {
1816
                foreach ($children as $i => $n) {
1817
                    if ((string) ($n['label'] ?? '') === $label) {
1818
                        return $i;
1819
                    }
1820
                }
1821
1822
                return null;
1823
            };
1824
1825
            foreach ($docs as $obj) {
1826
                if (!\is_object($obj)) {
1827
                    continue;
1828
                }
1829
1830
                $title = (string) $obj->title;
1831
                $filetype = (string) ($obj->filetype ?? $obj->file_type ?? '');
1832
                $rel = $normalize((string) $obj->path, $title, $filetype);
1833
                $parts = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen'));
1834
1835
                $cursor = &$docRoot;
1836
                $soFar = '';
1837
                $total = \count($parts);
1838
1839
                for ($i = 0; $i < $total; $i++) {
1840
                    $seg = $parts[$i];
1841
                    $isLast = ($i === $total - 1);
1842
                    $isFolder = (!$isLast) || ('folder' === $filetype);
1843
1844
                    $soFar = ltrim($soFar.'/'.$seg, '/');
1845
                    $label = $seg.($isFolder ? '/' : '');
1846
1847
                    $idx = $findChild($cursor, $label);
1848
                    if (null === $idx) {
1849
                        if ($isFolder) {
1850
                            $folderId = $folderIdByPath[strtolower($soFar)] ?? null;
1851
                            $node = [
1852
                                'id' => $folderId ?? ('dir:'.$soFar),
1853
                                'label' => $label,
1854
                                'selectable' => true,
1855
                                'children' => [],
1856
                            ];
1857
                        } else {
1858
                            $node = [
1859
                                'id' => (int) $obj->source_id,
1860
                                'label' => $label,
1861
                                'selectable' => true,
1862
                            ];
1863
                        }
1864
                        $cursor[] = $node;
1865
                        $idx = \count($cursor) - 1;
1866
                    }
1867
1868
                    if ($isFolder) {
1869
                        if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) {
1870
                            $cursor[$idx]['children'] = [];
1871
                        }
1872
                        $cursor = &$cursor[$idx]['children'];
1873
                    }
1874
                }
1875
            }
1876
1877
            $sortTree = null;
1878
            $sortTree = function (array &$nodes) use (&$sortTree): void {
1879
                usort($nodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1880
                foreach ($nodes as &$n) {
1881
                    if (isset($n['children']) && \is_array($n['children'])) {
1882
                        $sortTree($n['children']);
1883
                    }
1884
                }
1885
            };
1886
            $sortTree($docRoot);
1887
1888
            $tree[] = [
1889
                'type' => 'document',
1890
                'title' => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'),
1891
                'children' => $docRoot,
1892
            ];
1893
1894
            $skipTypes['document'] = true;
1895
        }
1896
1897
        // Forums block
1898
        $hasForumData =
1899
            (!empty($resources['forum']) || !empty($resources['Forum']))
1900
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1901
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1902
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1903
1904
        if ($hasForumData) {
1905
            $tree[] = $this->buildForumTreeForVue(
1906
                $course,
1907
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1908
            );
1909
            $skipTypes['forum'] = true;
1910
            $skipTypes['forum_category'] = true;
1911
            $skipTypes['forum_topic'] = true;
1912
            $skipTypes['forum_post'] = true;
1913
            $skipTypes['thread'] = true;
1914
            $skipTypes['post'] = true;
1915
        }
1916
1917
        // Links block (Category → Link)
1918
        $hasLinkData =
1919
            (!empty($resources['link']) || !empty($resources['Link']))
1920
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1921
1922
        if ($hasLinkData) {
1923
            $tree[] = $this->buildLinkTreeForVue(
1924
                $course,
1925
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1926
            );
1927
            $skipTypes['link'] = true;
1928
            $skipTypes['link_category'] = true;
1929
        }
1930
1931
        foreach ($resources as $rawType => $items) {
1932
            if (!\is_array($items) || empty($items)) {
1933
                continue;
1934
            }
1935
            $typeKey = $this->normalizeTypeKey($rawType);
1936
            if (isset($skipTypes[$typeKey])) {
1937
                continue;
1938
            }
1939
1940
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1941
            $group = [
1942
                'type' => $typeKey,
1943
                'title' => (string) $groupTitle,
1944
                'items' => [],
1945
            ];
1946
1947
            if ('gradebook' === $typeKey) {
1948
                $group['items'][] = [
1949
                    'id' => 'all',
1950
                    'label' => 'Gradebook (all)',
1951
                    'extra' => new stdClass(),
1952
                    'selectable' => true,
1953
                ];
1954
                $tree[] = $group;
1955
1956
                continue;
1957
            }
1958
1959
            foreach ($items as $id => $obj) {
1960
                if (!\is_object($obj)) {
1961
                    continue;
1962
                }
1963
1964
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1965
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1966
                    continue;
1967
                }
1968
1969
                if (!$this->isSelectableItem($typeKey, $obj)) {
1970
                    continue;
1971
                }
1972
1973
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1974
1975
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1976
                    $label = $idKey;
1977
                }
1978
1979
                $extra = $this->buildExtra($typeKey, $obj);
1980
1981
                $group['items'][] = [
1982
                    'id' => $idKey,
1983
                    'label' => $label,
1984
                    'extra' => $extra ?: new stdClass(),
1985
                    'selectable' => true,
1986
                ];
1987
            }
1988
1989
            if (!empty($group['items'])) {
1990
                usort(
1991
                    $group['items'],
1992
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1993
                );
1994
                $tree[] = $group;
1995
            }
1996
        }
1997
1998
        // Preferred order
1999
        $preferredOrder = [
2000
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
2001
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook',
2002
        ];
2003
        usort($tree, static function ($a, $b) use ($preferredOrder) {
2004
            $ia = array_search($a['type'], $preferredOrder, true);
2005
            $ib = array_search($b['type'], $preferredOrder, true);
2006
            if (false !== $ia && false !== $ib) {
2007
                return $ia <=> $ib;
2008
            }
2009
            if (false !== $ia) {
2010
                return -1;
2011
            }
2012
            if (false !== $ib) {
2013
                return 1;
2014
            }
2015
2016
            return strcasecmp($a['title'], $b['title']);
2017
        });
2018
2019
        if ($this->debug) {
2020
            $this->logDebug(
2021
                '[buildResourceTreeForVue] end groups',
2022
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? []), 'children' => \count($g['children'] ?? [])], $tree)
2023
            );
2024
        }
2025
2026
        return $tree;
2027
    }
2028
2029
    /**
2030
     * Build forum tree (Category → Forum → Topic) for the UI.
2031
     * Uses only "items" (no "children") and sets UI hints (has_children, item_count).
2032
     */
2033
    private function buildForumTreeForVue(object $course, string $groupTitle): array
2034
    {
2035
        $this->logDebug('[buildForumTreeForVue] start');
2036
2037
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2038
2039
        // Buckets (defensive: accept legacy casings / aliases)
2040
        $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
2041
        $forumRaw = $res['forum'] ?? $res['Forum'] ?? [];
2042
        $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []);
2043
        $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []);
2044
2045
        $this->logDebug('[buildForumTreeForVue] raw counts', [
2046
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
2047
            'forums' => \is_array($forumRaw) ? \count($forumRaw) : 0,
2048
            'topics' => \is_array($topicRaw) ? \count($topicRaw) : 0,
2049
            'posts' => \is_array($postRaw) ? \count($postRaw) : 0,
2050
        ]);
2051
2052
        // Quick classifiers (defensive)
2053
        $isForum = function (object $o): bool {
2054
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2055
            if (isset($e->forum_title) && \is_string($e->forum_title)) {
2056
                return true;
2057
            }
2058
            if (isset($e->default_view) || isset($e->allow_anonymous)) {
2059
                return true;
2060
            }
2061
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) {
2062
                return true;
2063
            }
2064
2065
            return false;
2066
        };
2067
        $isTopic = function (object $o): bool {
2068
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2069
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) {
2070
                return true;
2071
            }
2072
            if (isset($e->forum_id) && !isset($e->forum_title)) {
2073
                return true;
2074
            }
2075
2076
            return false;
2077
        };
2078
        $getForumCategoryId = function (object $forum): int {
2079
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
2080
            $cid = (int) ($e->forum_category ?? 0);
2081
            if ($cid <= 0) {
2082
                $cid = (int) ($e->forum_category_id ?? 0);
2083
            }
2084
            if ($cid <= 0) {
2085
                $cid = (int) ($e->category_id ?? 0);
2086
            }
2087
2088
            return $cid;
2089
        };
2090
2091
        // Build categories
2092
        $cats = [];
2093
        foreach ($catRaw as $id => $obj) {
2094
            $id = (int) $id;
2095
            if ($id <= 0 || !\is_object($obj)) {
2096
                continue;
2097
            }
2098
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
2099
            $cats[$id] = [
2100
                'id' => $id,
2101
                'type' => 'forum_category',
2102
                'label' => ('' !== $label ? $label : 'Category #'.$id).'/',
2103
                'selectable' => true,
2104
                'items' => [],
2105
                'has_children' => false,
2106
                'item_count' => 0,
2107
                'extra' => ['filetype' => 'folder'],
2108
            ];
2109
        }
2110
        // Virtual "Uncategorized"
2111
        $uncatKey = -9999;
2112
        if (!isset($cats[$uncatKey])) {
2113
            $cats[$uncatKey] = [
2114
                'id' => $uncatKey,
2115
                'type' => 'forum_category',
2116
                'label' => 'Uncategorized/',
2117
                'selectable' => true,
2118
                'items' => [],
2119
                '_virtual' => true,
2120
                'has_children' => false,
2121
                'item_count' => 0,
2122
                'extra' => ['filetype' => 'folder'],
2123
            ];
2124
        }
2125
2126
        // Forums
2127
        $forums = [];
2128
        foreach ($forumRaw as $id => $obj) {
2129
            $id = (int) $id;
2130
            if ($id <= 0 || !\is_object($obj)) {
2131
                continue;
2132
            }
2133
            if (!$isForum($obj)) {
2134
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]);
2135
2136
                continue;
2137
            }
2138
            $forums[$id] = $this->objectEntity($obj);
2139
        }
2140
2141
        // Topics (+ post counts)
2142
        $topics = [];
2143
        $postCountByTopic = [];
2144
        foreach ($topicRaw as $id => $obj) {
2145
            $id = (int) $id;
2146
            if ($id <= 0 || !\is_object($obj)) {
2147
                continue;
2148
            }
2149
            if ($isForum($obj) && !$isTopic($obj)) {
2150
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]);
2151
2152
                continue;
2153
            }
2154
            if (!$isTopic($obj)) {
2155
                continue;
2156
            }
2157
            $topics[$id] = $this->objectEntity($obj);
2158
        }
2159
        foreach ($postRaw as $id => $obj) {
2160
            $id = (int) $id;
2161
            if ($id <= 0 || !\is_object($obj)) {
2162
                continue;
2163
            }
2164
            $e = $this->objectEntity($obj);
2165
            $tid = (int) ($e->thread_id ?? 0);
2166
            if ($tid > 0) {
2167
                $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1;
2168
            }
2169
        }
2170
2171
        // Attach topics to forums and forums to categories
2172
        foreach ($forums as $fid => $f) {
2173
            $catId = $getForumCategoryId($f);
2174
            if (!isset($cats[$catId])) {
2175
                $catId = $uncatKey;
2176
            }
2177
2178
            $forumNode = [
2179
                'id' => $fid,
2180
                'type' => 'forum',
2181
                'label' => $this->resolveItemLabel('forum', $f, $fid),
2182
                'extra' => $this->buildExtra('forum', $f) ?: new stdClass(),
2183
                'selectable' => true,
2184
                'items' => [],
2185
                // UI hints
2186
                'has_children' => false,
2187
                'item_count' => 0,
2188
                'ui_depth' => 2,
2189
            ];
2190
2191
            foreach ($topics as $tid => $t) {
2192
                if ((int) ($t->forum_id ?? 0) !== $fid) {
2193
                    continue;
2194
                }
2195
2196
                $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
2197
                $date = (string) ($t->thread_date ?? '');
2198
                $nPosts = (int) ($postCountByTopic[$tid] ?? 0);
2199
2200
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
2201
                $meta = [];
2202
                if ('' !== $author) {
2203
                    $meta[] = $author;
2204
                }
2205
                if ('' !== $date) {
2206
                    $meta[] = $date;
2207
                }
2208
                if ($meta) {
2209
                    $topicLabel .= ' ('.implode(', ', $meta).')';
2210
                }
2211
                if ($nPosts > 0) {
2212
                    $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's');
2213
                }
2214
2215
                $forumNode['items'][] = [
2216
                    'id' => $tid,
2217
                    'type' => 'forum_topic',
2218
                    'label' => $topicLabel,
2219
                    'extra' => new stdClass(),
2220
                    'selectable' => true,
2221
                    'ui_depth' => 3,
2222
                    'item_count' => 0,
2223
                ];
2224
            }
2225
2226
            if (!empty($forumNode['items'])) {
2227
                usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2228
                $forumNode['has_children'] = true;
2229
                $forumNode['item_count'] = \count($forumNode['items']);
2230
            }
2231
2232
            $cats[$catId]['items'][] = $forumNode;
2233
        }
2234
2235
        // Remove empty virtual category; sort forums inside each category
2236
        $catNodes = array_values(array_filter($cats, static function ($c) {
2237
            if (!empty($c['_virtual']) && empty($c['items'])) {
2238
                return false;
2239
            }
2240
2241
            return true;
2242
        }));
2243
2244
        // Flatten stray forums (defensive) and finalize UI hints
2245
        foreach ($catNodes as &$cat) {
2246
            if (!empty($cat['items'])) {
2247
                $lift = [];
2248
                foreach ($cat['items'] as &$forumNode) {
2249
                    if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) {
2250
                        continue;
2251
                    }
2252
                    $keep = [];
2253
                    foreach ($forumNode['items'] as $child) {
2254
                        if (($child['type'] ?? '') === 'forum') {
2255
                            $lift[] = $child;
2256
                            $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [
2257
                                'parent_forum_id' => $forumNode['id'] ?? null,
2258
                                'lifted_forum_id' => $child['id'] ?? null,
2259
                                'cat_id' => $cat['id'] ?? null,
2260
                            ]);
2261
                        } else {
2262
                            $keep[] = $child;
2263
                        }
2264
                    }
2265
                    $forumNode['items'] = $keep;
2266
                    $forumNode['has_children'] = !empty($keep);
2267
                    $forumNode['item_count'] = \count($keep);
2268
                }
2269
                unset($forumNode);
2270
2271
                foreach ($lift as $n) {
2272
                    $cat['items'][] = $n;
2273
                }
2274
                usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2275
            }
2276
2277
            // UI hints for category
2278
            $cat['has_children'] = !empty($cat['items']);
2279
            $cat['item_count'] = \count($cat['items'] ?? []);
2280
        }
2281
        unset($cat);
2282
2283
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
2284
2285
        return [
2286
            'type' => 'forum',
2287
            'title' => $groupTitle,
2288
            'items' => $catNodes,
2289
        ];
2290
    }
2291
2292
    /**
2293
     * Normalize a raw type to a lowercase key.
2294
     */
2295
    private function normalizeTypeKey(int|string $raw): string
2296
    {
2297
        if (\is_int($raw)) {
2298
            return (string) $raw;
2299
        }
2300
2301
        $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw));
2302
2303
        $map = [
2304
            'forum_category' => 'forum_category',
2305
            'forumtopic' => 'forum_topic',
2306
            'forum_topic' => 'forum_topic',
2307
            'forum_post' => 'forum_post',
2308
            'thread' => 'forum_topic',
2309
            'post' => 'forum_post',
2310
            'exercise_question' => 'exercise_question',
2311
            'surveyquestion' => 'survey_question',
2312
            'surveyinvitation' => 'survey_invitation',
2313
            'survey' => 'survey',
2314
            'link_category' => 'link_category',
2315
            'coursecopylearnpath' => 'learnpath',
2316
            'coursecopytestcategory' => 'test_category',
2317
            'coursedescription' => 'course_description',
2318
            'session_course' => 'session_course',
2319
            'gradebookbackup' => 'gradebook',
2320
            'scormdocument' => 'scorm',
2321
            'tool/introduction' => 'tool_intro',
2322
            'tool_introduction' => 'tool_intro',
2323
        ];
2324
2325
        return $map[$s] ?? $s;
2326
    }
2327
2328
    /**
2329
     * Keys to skip as top-level groups in UI.
2330
     *
2331
     * @return array<string,bool>
2332
     */
2333
    private function getSkipTypeKeys(): array
2334
    {
2335
        return [
2336
            'forum_category' => true,
2337
            'forum_topic' => true,
2338
            'forum_post' => true,
2339
            'thread' => true,
2340
            'post' => true,
2341
            'exercise_question' => true,
2342
            'survey_question' => true,
2343
            'survey_invitation' => true,
2344
            'session_course' => true,
2345
            'scorm' => true,
2346
            'asset' => true,
2347
            'link_category' => true,
2348
        ];
2349
    }
2350
2351
    /**
2352
     * Default labels for groups.
2353
     *
2354
     * @return array<string,string>
2355
     */
2356
    private function getDefaultTypeTitles(): array
2357
    {
2358
        return [
2359
            'announcement' => 'Announcements',
2360
            'document' => 'Documents',
2361
            'glossary' => 'Glossaries',
2362
            'calendar_event' => 'Calendar events',
2363
            'event' => 'Calendar events',
2364
            'link' => 'Links',
2365
            'course_description' => 'Course descriptions',
2366
            'learnpath' => 'Parcours',
2367
            'learnpath_category' => 'Learning path categories',
2368
            'forum' => 'Forums',
2369
            'forum_category' => 'Forum categories',
2370
            'quiz' => 'Exercices',
2371
            'test_category' => 'Test categories',
2372
            'wiki' => 'Wikis',
2373
            'thematic' => 'Thematics',
2374
            'attendance' => 'Attendances',
2375
            'work' => 'Works',
2376
            'session_course' => 'Session courses',
2377
            'gradebook' => 'Gradebook',
2378
            'scorm' => 'SCORM packages',
2379
            'survey' => 'Surveys',
2380
            'survey_question' => 'Survey questions',
2381
            'survey_invitation' => 'Survey invitations',
2382
            'asset' => 'Assets',
2383
            'tool_intro' => 'Tool introductions',
2384
        ];
2385
    }
2386
2387
    /**
2388
     * Decide if an item is selectable (UI).
2389
     */
2390
    private function isSelectableItem(string $type, object $obj): bool
2391
    {
2392
        if ('document' === $type) {
2393
            return true;
2394
        }
2395
2396
        return true;
2397
    }
2398
2399
    /**
2400
     * Resolve label for an item with fallbacks.
2401
     */
2402
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
2403
    {
2404
        $entity = $this->objectEntity($obj);
2405
2406
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
2407
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
2408
                return trim((string) $entity->{$k});
2409
            }
2410
        }
2411
2412
        if (isset($obj->params) && \is_array($obj->params)) {
2413
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
2414
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
2415
                    return (string) $obj->params[$k];
2416
                }
2417
            }
2418
        }
2419
2420
        switch ($type) {
2421
            case 'document':
2422
                // 1) ruta cruda tal como viene del backup/DB
2423
                $raw = (string) ($entity->path ?? $obj->path ?? '');
2424
                if ('' !== $raw) {
2425
                    // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup
2426
                    $rel = ltrim($raw, '/');
2427
                    $rel = preg_replace('~^document/?~', '', $rel);
2428
2429
                    // 3) carpeta ⇒ que termine con "/"
2430
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
2431
                    if ('folder' === $fileType) {
2432
                        $rel = rtrim($rel, '/').'/';
2433
                    }
2434
2435
                    // 4) si la ruta quedó vacía, usa basename como último recurso
2436
                    return '' !== $rel ? $rel : basename($raw);
2437
                }
2438
2439
                // fallback: título o nombre de archivo
2440
                if (!empty($obj->title)) {
2441
                    return (string) $obj->title;
2442
                }
2443
2444
                break;
2445
2446
            case 'course_description':
2447
                if (!empty($obj->title)) {
2448
                    return (string) $obj->title;
2449
                }
2450
                $t = (int) ($obj->description_type ?? 0);
2451
                $names = [
2452
                    1 => 'Description',
2453
                    2 => 'Objectives',
2454
                    3 => 'Topics',
2455
                    4 => 'Methodology',
2456
                    5 => 'Course material',
2457
                    6 => 'Resources',
2458
                    7 => 'Assessment',
2459
                    8 => 'Custom',
2460
                ];
2461
2462
                return $names[$t] ?? ('#'.$fallbackId);
2463
2464
            case 'announcement':
2465
                if (!empty($obj->title)) {
2466
                    return (string) $obj->title;
2467
                }
2468
2469
                break;
2470
2471
            case 'forum':
2472
                if (!empty($entity->forum_title)) {
2473
                    return (string) $entity->forum_title;
2474
                }
2475
2476
                break;
2477
2478
            case 'forum_category':
2479
                if (!empty($entity->cat_title)) {
2480
                    return (string) $entity->cat_title;
2481
                }
2482
2483
                break;
2484
2485
            case 'link':
2486
                if (!empty($obj->title)) {
2487
                    return (string) $obj->title;
2488
                }
2489
                if (!empty($obj->url)) {
2490
                    return (string) $obj->url;
2491
                }
2492
2493
                break;
2494
2495
            case 'survey':
2496
                if (!empty($obj->title)) {
2497
                    return trim((string) $obj->title);
2498
                }
2499
2500
                break;
2501
2502
            case 'learnpath':
2503
                if (!empty($obj->name)) {
2504
                    return (string) $obj->name;
2505
                }
2506
2507
                break;
2508
2509
            case 'thematic':
2510
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
2511
                    return (string) $obj->params['title'];
2512
                }
2513
2514
                break;
2515
2516
            case 'quiz':
2517
                if (!empty($entity->title)) {
2518
                    return (string) $entity->title;
2519
                }
2520
2521
                break;
2522
2523
            case 'forum_topic':
2524
                if (!empty($entity->thread_title)) {
2525
                    return (string) $entity->thread_title;
2526
                }
2527
2528
                break;
2529
        }
2530
2531
        return '#'.$fallbackId;
2532
    }
2533
2534
    /**
2535
     * Extract wrapped entity (->obj) or the object itself.
2536
     */
2537
    private function objectEntity(object $resource): object
2538
    {
2539
        if (isset($resource->obj) && \is_object($resource->obj)) {
2540
            return $resource->obj;
2541
        }
2542
2543
        return $resource;
2544
    }
2545
2546
    /**
2547
     * Extra payload per item for UI (optional).
2548
     */
2549
    private function buildExtra(string $type, object $obj): array
2550
    {
2551
        $extra = [];
2552
2553
        $get = static function (object $o, string $k, $default = null) {
2554
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
2555
        };
2556
2557
        switch ($type) {
2558
            case 'document':
2559
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
2560
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
2561
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
2562
2563
                break;
2564
2565
            case 'link':
2566
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
2567
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
2568
2569
                break;
2570
2571
            case 'forum':
2572
                $entity = $this->objectEntity($obj);
2573
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
2574
                $extra['default_view'] = (string) ($entity->default_view ?? '');
2575
2576
                break;
2577
2578
            case 'learnpath':
2579
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
2580
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
2581
                    return [
2582
                        'id' => (int) ($i['id'] ?? 0),
2583
                        'title' => (string) ($i['title'] ?? ''),
2584
                        'type' => (string) ($i['item_type'] ?? ''),
2585
                        'path' => (string) ($i['path'] ?? ''),
2586
                    ];
2587
                }, $obj->items) : [];
2588
2589
                break;
2590
2591
            case 'thematic':
2592
                if (isset($obj->params) && \is_array($obj->params)) {
2593
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
2594
                }
2595
2596
                break;
2597
2598
            case 'quiz':
2599
                $entity = $this->objectEntity($obj);
2600
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2601
                    ? array_map('intval', $entity->question_ids)
2602
                    : [];
2603
2604
                break;
2605
2606
            case 'survey':
2607
                $entity = $this->objectEntity($obj);
2608
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2609
                    ? array_map('intval', $entity->question_ids)
2610
                    : [];
2611
2612
                break;
2613
        }
2614
2615
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
2616
    }
2617
2618
    // --------------------------------------------------------------------------------
2619
    // Selection filtering (used by partial restore)
2620
    // --------------------------------------------------------------------------------
2621
2622
    /**
2623
     * Get first existing key from candidates.
2624
     */
2625
    private function firstExistingKey(array $orig, array $candidates): ?string
2626
    {
2627
        foreach ($candidates as $k) {
2628
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
2629
                return $k;
2630
            }
2631
        }
2632
2633
        return null;
2634
    }
2635
2636
    /**
2637
     * Filter legacy Course by UI selections (and pull dependencies).
2638
     *
2639
     * @param array $selected [type => [id => true]]
2640
     */
2641
    private function filterLegacyCourseBySelection(object $course, array $selected): object
2642
    {
2643
        // Sanitize incoming selection (frontend sometimes sends synthetic groups)
2644
        $selected = array_filter($selected, 'is_array');
2645
        unset($selected['undefined']);
2646
2647
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
2648
2649
        if (empty($course->resources) || !\is_array($course->resources)) {
2650
            $this->logDebug('[filterSelection] course has no resources');
2651
2652
            return $course;
2653
        }
2654
2655
        /** @var array<string,mixed> $orig */
2656
        $orig = $course->resources;
2657
2658
        // Preserve meta buckets (keys that start with "__")
2659
        $__metaBuckets = [];
2660
        foreach ($orig as $k => $v) {
2661
            if (\is_string($k) && str_starts_with($k, '__')) {
2662
                $__metaBuckets[$k] = $v;
2663
            }
2664
        }
2665
2666
        $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
2667
2668
        // ---------- Forums flow ----------
2669
        if (!empty($selected['forum'])) {
2670
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
2671
            if (!empty($selForums)) {
2672
                // tolerant lookups
2673
                $forums = $this->findBucket($orig, 'forum');
2674
                $threads = $this->findBucket($orig, 'forum_topic');
2675
                $posts = $this->findBucket($orig, 'forum_post');
2676
2677
                $catsToKeep = [];
2678
2679
                foreach ($forums as $fid => $f) {
2680
                    if (!isset($selForums[(string) $fid])) {
2681
                        continue;
2682
                    }
2683
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
2684
                    $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
2685
                    if ($cid > 0) {
2686
                        $catsToKeep[$cid] = true;
2687
                    }
2688
                }
2689
2690
                $threadToKeep = [];
2691
                foreach ($threads as $tid => $t) {
2692
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
2693
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
2694
                        $threadToKeep[(int) $tid] = true;
2695
                    }
2696
                }
2697
2698
                $postToKeep = [];
2699
                foreach ($posts as $pid => $p) {
2700
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
2701
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
2702
                        $postToKeep[(int) $pid] = true;
2703
                    }
2704
                }
2705
2706
                $out = [];
2707
                foreach ($selected as $type => $ids) {
2708
                    if (!\is_array($ids) || empty($ids)) {
2709
                        continue;
2710
                    }
2711
                    $bucket = $this->findBucket($orig, (string) $type);
2712
                    $key = $this->findBucketKey($orig, (string) $type);
2713
                    if (null !== $key && !empty($bucket)) {
2714
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2715
                        $out[$key] = $this->intersectBucketByIds($bucket, $idsMap);
2716
                    }
2717
                }
2718
2719
                $forumCat = $this->findBucket($orig, 'forum_category');
2720
                $forumBucket = $this->findBucket($orig, 'forum');
2721
                $threadBucket = $this->findBucket($orig, 'forum_topic');
2722
                $postBucket = $this->findBucket($orig, 'forum_post');
2723
2724
                if (!empty($forumCat) && !empty($catsToKeep)) {
2725
                    $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] =
2726
                        array_intersect_key(
2727
                            $forumCat,
2728
                            array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
2729
                        );
2730
                }
2731
2732
                if (!empty($forumBucket)) {
2733
                    $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] =
2734
                        array_intersect_key($forumBucket, $selForums);
2735
                }
2736
                if (!empty($threadBucket)) {
2737
                    $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] =
2738
                        array_intersect_key(
2739
                            $threadBucket,
2740
                            array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
2741
                        );
2742
                }
2743
                if (!empty($postBucket)) {
2744
                    $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] =
2745
                        array_intersect_key(
2746
                            $postBucket,
2747
                            array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
2748
                        );
2749
                }
2750
2751
                // If we have forums but no Forum_Category (edge), keep original categories
2752
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
2753
                    $out['Forum_Category'] = $forumCat;
2754
                }
2755
2756
                $out = array_filter($out);
2757
                $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out;
2758
2759
                $this->logDebug('[filterSelection] end (forums)', [
2760
                    'kept_types' => array_keys($course->resources),
2761
                    'forum_counts' => [
2762
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
2763
                        'forum' => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
2764
                        'thread' => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
2765
                        'post' => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
2766
                    ],
2767
                ]);
2768
2769
                return $course;
2770
            }
2771
        }
2772
2773
        // ---------- Generic + quiz/survey/gradebook ----------
2774
        $keep = [];
2775
        foreach ($selected as $type => $ids) {
2776
            if (!\is_array($ids) || empty($ids)) {
2777
                continue;
2778
            }
2779
            $legacyKey = $this->findBucketKey($orig, (string) $type);
2780
            if (null === $legacyKey) {
2781
                continue;
2782
            }
2783
            $bucket = $orig[$legacyKey] ?? [];
2784
            if (!empty($bucket) && \is_array($bucket)) {
2785
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2786
                $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap);
2787
            }
2788
        }
2789
2790
        // Gradebook
2791
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
2792
        if ($gbKey && !empty($selected['gradebook'])) {
2793
            $gbBucket = $getBucket($orig, $gbKey);
2794
            if (!empty($gbBucket)) {
2795
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
2796
                $firstItem = reset($gbBucket);
2797
2798
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
2799
                    $keep[$gbKey] = $gbBucket;
2800
                    $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]);
2801
                } else {
2802
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
2803
                    $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
2804
                }
2805
            }
2806
        }
2807
2808
        // Quizzes -> questions (+ images)
2809
        $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']);
2810
        if ($quizKey && !empty($keep[$quizKey])) {
2811
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
2812
            if ($questionKey) {
2813
                $qids = [];
2814
                foreach ($keep[$quizKey] as $qid => $qwrap) {
2815
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2816
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
2817
                        foreach ($q->question_ids as $sid) {
2818
                            $qids[(string) $sid] = true;
2819
                        }
2820
                    }
2821
                }
2822
                if (!empty($qids)) {
2823
                    $questionBucket = $getBucket($orig, $questionKey);
2824
                    $selQ = array_intersect_key($questionBucket, $qids);
2825
                    if (!empty($selQ)) {
2826
                        $keep[$questionKey] = $selQ;
2827
2828
                        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2829
                        if ($docKey) {
2830
                            $docBucket = $getBucket($orig, $docKey);
2831
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
2832
                            if (!empty($imageQuizBucket)) {
2833
                                $needed = [];
2834
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
2835
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2836
                                    $pic = (string) ($q->picture ?? '');
2837
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
2838
                                        $needed[$pic] = true;
2839
                                    }
2840
                                }
2841
                                if (!empty($needed)) {
2842
                                    $keep[$docKey] = $keep[$docKey] ?? [];
2843
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
2844
                                }
2845
                            }
2846
                        }
2847
                    }
2848
                }
2849
            } else {
2850
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found');
2851
            }
2852
        }
2853
2854
        // Surveys -> questions (+ invitations)
2855
        $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']);
2856
        if ($surveyKey && !empty($keep[$surveyKey])) {
2857
            $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
2858
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
2859
2860
            if ($surveyQuestionKey) {
2861
                $neededQids = [];
2862
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
2863
2864
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
2865
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
2866
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
2867
                        foreach ($s->question_ids as $qid) {
2868
                            $neededQids[(string) $qid] = true;
2869
                        }
2870
                    }
2871
                }
2872
                if (empty($neededQids)) {
2873
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2874
                    foreach ($surveyQBucket as $qid => $qWrap) {
2875
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2876
                        $qSurveyId = (string) ($q->survey_id ?? '');
2877
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2878
                            $neededQids[(string) $qid] = true;
2879
                        }
2880
                    }
2881
                }
2882
                if (!empty($neededQids)) {
2883
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2884
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2885
                }
2886
            } else {
2887
                $this->logDebug('[filterSelection] surveys selected but no question bucket found');
2888
            }
2889
2890
            if ($surveyInvitationKey) {
2891
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2892
                if (!empty($invBucket)) {
2893
                    $neededInv = [];
2894
                    foreach ($invBucket as $iid => $invWrap) {
2895
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2896
                        $sid = (string) ($inv->survey_id ?? '');
2897
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2898
                            $neededInv[(string) $iid] = true;
2899
                        }
2900
                    }
2901
                    if (!empty($neededInv)) {
2902
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2903
                    }
2904
                }
2905
            }
2906
        }
2907
2908
        // Documents: add parent folders for selected files
2909
        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2910
        if ($docKey && !empty($keep[$docKey])) {
2911
            $docBucket = $getBucket($orig, $docKey);
2912
2913
            $foldersByRel = [];
2914
            foreach ($docBucket as $fid => $res) {
2915
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2916
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2917
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2918
                if (!$isFolder) {
2919
                    continue;
2920
                }
2921
2922
                $p = (string) ($e->path ?? '');
2923
                if ('' === $p) {
2924
                    continue;
2925
                }
2926
2927
                $frel = '/'.ltrim(substr($p, 8), '/');
2928
                $frel = rtrim($frel, '/').'/';
2929
                if ('//' !== $frel) {
2930
                    $foldersByRel[$frel] = $fid;
2931
                }
2932
            }
2933
2934
            $needFolderIds = [];
2935
            foreach ($keep[$docKey] as $id => $res) {
2936
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2937
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2938
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2939
                if ($isFolder) {
2940
                    continue;
2941
                }
2942
2943
                $p = (string) ($e->path ?? '');
2944
                if ('' === $p) {
2945
                    continue;
2946
                }
2947
2948
                $rel = '/'.ltrim(substr($p, 8), '/');
2949
                $dir = rtrim(\dirname($rel), '/');
2950
                if ('' === $dir) {
2951
                    continue;
2952
                }
2953
2954
                $acc = '';
2955
                foreach (array_filter(explode('/', $dir)) as $seg) {
2956
                    $acc .= '/'.$seg;
2957
                    $accKey = rtrim($acc, '/').'/';
2958
                    if (isset($foldersByRel[$accKey])) {
2959
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2960
                    }
2961
                }
2962
            }
2963
            if (!empty($needFolderIds)) {
2964
                $added = array_intersect_key($docBucket, $needFolderIds);
2965
                $keep[$docKey] += $added;
2966
            }
2967
        }
2968
2969
        // Links -> pull categories used by the selected links
2970
        $lnkKey = $this->firstExistingKey($orig, ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']);
2971
        if ($lnkKey && !empty($keep[$lnkKey])) {
2972
            $catIdsUsed = [];
2973
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2974
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2975
                $cid = (int) ($L->category_id ?? 0);
2976
                if ($cid > 0) {
2977
                    $catIdsUsed[(string) $cid] = true;
2978
                }
2979
            }
2980
2981
            $catKey = $this->firstExistingKey($orig, ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']);
2982
            if ($catKey && !empty($catIdsUsed)) {
2983
                $catBucket = $getBucket($orig, $catKey);
2984
                if (!empty($catBucket)) {
2985
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
2986
                    $keep[$catKey] = $subset;
2987
                    $keep['link_category'] = $subset; // mirror for convenience
2988
                }
2989
            }
2990
        }
2991
2992
        $keep = array_filter($keep);
2993
        $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep;
2994
2995
        $this->logDebug('[filterSelection] non-forum flow end', [
2996
            'selected_types' => array_keys($selected),
2997
            'orig_types' => array_keys($orig),
2998
            'kept_types' => array_keys($course->resources ?? []),
2999
        ]);
3000
3001
        return $course;
3002
    }
3003
3004
    /**
3005
     * Map UI options (1/2/3) to legacy file policy.
3006
     */
3007
    private function mapSameNameOption(int $opt): int
3008
    {
3009
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
3010
3011
        if (!\defined('FILE_SKIP')) {
3012
            \define('FILE_SKIP', 1);
3013
        }
3014
        if (!\defined('FILE_RENAME')) {
3015
            \define('FILE_RENAME', 2);
3016
        }
3017
        if (!\defined('FILE_OVERWRITE')) {
3018
            \define('FILE_OVERWRITE', 3);
3019
        }
3020
3021
        return match ($opt) {
3022
            1 => FILE_SKIP,
3023
            3 => FILE_OVERWRITE,
3024
            default => FILE_RENAME,
3025
        };
3026
    }
3027
3028
    /**
3029
     * Set debug mode from Request (query/header).
3030
     */
3031
    private function setDebugFromRequest(?Request $req): void
3032
    {
3033
        if (!$req) {
3034
            return;
3035
        }
3036
        // Query param wins
3037
        if ($req->query->has('debug')) {
3038
            $this->debug = $req->query->getBoolean('debug');
3039
3040
            return;
3041
        }
3042
        // Fallback to header
3043
        $hdr = $req->headers->get('X-Debug');
3044
        if (null !== $hdr) {
3045
            $val = trim((string) $hdr);
3046
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
3047
        }
3048
    }
3049
3050
    /**
3051
     * Debug logger with stage + compact JSON payload.
3052
     */
3053
    private function logDebug(string $stage, mixed $payload = null): void
3054
    {
3055
        if (!$this->debug) {
3056
            return;
3057
        }
3058
        $prefix = 'COURSE_DEBUG';
3059
        if (null === $payload) {
3060
            error_log("$prefix: $stage");
3061
3062
            return;
3063
        }
3064
        // Safe/short json
3065
        $json = null;
3066
3067
        try {
3068
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
3069
            if (null !== $json && \strlen($json) > 8000) {
3070
                $json = substr($json, 0, 8000).'…(truncated)';
3071
            }
3072
        } catch (Throwable $e) {
3073
            $json = '[payload_json_error: '.$e->getMessage().']';
3074
        }
3075
        error_log("$prefix: $stage -> $json");
3076
    }
3077
3078
    /**
3079
     * Snapshot of resources bag for quick inspection.
3080
     */
3081
    private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array
0 ignored issues
show
Unused Code introduced by
The method snapshotResources() 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...
3082
    {
3083
        $out = [];
3084
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3085
        $i = 0;
3086
        foreach ($res as $type => $bag) {
3087
            if ($i++ >= $maxTypes) {
3088
                $out['__notice'] = 'types truncated';
3089
3090
                break;
3091
            }
3092
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
3093
            if (\is_array($bag)) {
3094
                $j = 0;
3095
                foreach ($bag as $id => $obj) {
3096
                    if ($j++ >= $maxItemsPerType) {
3097
                        $snap['sample'][] = ['__notice' => 'truncated'];
3098
3099
                        break;
3100
                    }
3101
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3102
                    $snap['sample'][] = [
3103
                        'id' => (string) $id,
3104
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
3105
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
3106
                    ];
3107
                }
3108
            }
3109
            $out[(string) $type] = $snap;
3110
        }
3111
3112
        return $out;
3113
    }
3114
3115
    /**
3116
     * Snapshot of forum-family counters.
3117
     */
3118
    private function snapshotForumCounts(object $course): array
0 ignored issues
show
Unused Code introduced by
The method snapshotForumCounts() 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...
3119
    {
3120
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
3121
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
3122
3123
        return [
3124
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
3125
            'forum' => $get('forum', 'Forum'),
3126
            'thread' => $get('thread', 'forum_topic'),
3127
            'post' => $get('post', 'forum_post'),
3128
        ];
3129
    }
3130
3131
    /**
3132
     * Builds the selection map [type => [id => true]] from high-level types.
3133
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
3134
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
3135
     *
3136
     * @param object   $course        Legacy Course with already hydrated resources
3137
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
3138
     *
3139
     * @return array<string, array<int|string, bool>>
3140
     */
3141
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
3142
    {
3143
        $selectedTypes = array_map(
3144
            fn ($t) => $this->normalizeTypeKey((string) $t),
3145
            $selectedTypes
3146
        );
3147
3148
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3149
3150
        $coreDeps = [
3151
            'document', 'link', 'quiz', 'work', 'survey',
3152
            'Forum_Category', 'forum', 'thread', 'post',
3153
            'exercise_question', 'survey_question', 'link_category',
3154
        ];
3155
3156
        $presentKeys = array_fill_keys(array_map(
3157
            fn ($k) => $this->normalizeTypeKey((string) $k),
3158
            array_keys($res)
3159
        ), true);
3160
3161
        $out = [];
3162
3163
        $addBucket = function (string $typeKey) use (&$out, $res): void {
3164
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
3165
                return;
3166
            }
3167
            $ids = [];
3168
            foreach ($res[$typeKey] as $id => $_) {
3169
                $ids[(string) $id] = true;
3170
            }
3171
            if ($ids) {
3172
                $out[$typeKey] = $ids;
3173
            }
3174
        };
3175
3176
        foreach ($selectedTypes as $t) {
3177
            $addBucket($t);
3178
3179
            if ('learnpath' === $t) {
3180
                foreach ($coreDeps as $depRaw) {
3181
                    $dep = $this->normalizeTypeKey($depRaw);
3182
                    if (isset($presentKeys[$dep])) {
3183
                        $addBucket($dep);
3184
                    }
3185
                }
3186
            }
3187
        }
3188
3189
        $this->logDebug('[buildSelectionFromTypes] built', [
3190
            'selectedTypes' => $selectedTypes,
3191
            'kept_types' => array_keys($out),
3192
        ]);
3193
3194
        return $out;
3195
    }
3196
3197
    /**
3198
     * Build link tree (Category → Link) for the UI.
3199
     * Categories are not selectable; links are leaves (item_count = 0).
3200
     */
3201
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
3202
    {
3203
        $this->logDebug('[buildLinkTreeForVue] start');
3204
3205
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3206
        $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? [];
3207
        $linkRaw = $res['link'] ?? $res['Link'] ?? [];
3208
3209
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
3210
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
3211
            'links' => \is_array($linkRaw) ? \count($linkRaw) : 0,
3212
        ]);
3213
3214
        $cats = [];
3215
        foreach ($catRaw as $id => $obj) {
3216
            $id = (int) $id;
3217
            if ($id <= 0 || !\is_object($obj)) {
3218
                continue;
3219
            }
3220
            $e = $this->objectEntity($obj);
3221
            $label = $this->resolveItemLabel('link_category', $e, $id);
3222
            $cats[$id] = [
3223
                'id' => $id,
3224
                'type' => 'link_category',
3225
                'label' => (('' !== $label ? $label : ('Category #'.$id)).'/'),
3226
                'selectable' => true,
3227
                'items' => [],
3228
                'has_children' => false,
3229
                'item_count' => 0,
3230
                'extra' => ['filetype' => 'folder'],
3231
            ];
3232
        }
3233
3234
        // Virtual "Uncategorized"
3235
        $uncatKey = -9999;
3236
        if (!isset($cats[$uncatKey])) {
3237
            $cats[$uncatKey] = [
3238
                'id' => $uncatKey,
3239
                'type' => 'link_category',
3240
                'label' => 'Uncategorized/',
3241
                'selectable' => true,
3242
                'items' => [],
3243
                '_virtual' => true,
3244
                'has_children' => false,
3245
                'item_count' => 0,
3246
                'extra' => ['filetype' => 'folder'],
3247
            ];
3248
        }
3249
3250
        // Assign links to categories
3251
        foreach ($linkRaw as $id => $obj) {
3252
            $id = (int) $id;
3253
            if ($id <= 0 || !\is_object($obj)) {
3254
                continue;
3255
            }
3256
            $e = $this->objectEntity($obj);
3257
3258
            $cid = (int) ($e->category_id ?? 0);
3259
            if (!isset($cats[$cid])) {
3260
                $cid = $uncatKey;
3261
            }
3262
3263
            $cats[$cid]['items'][] = [
3264
                'id' => $id,
3265
                'type' => 'link',
3266
                'label' => $this->resolveItemLabel('link', $e, $id),
3267
                'extra' => $this->buildExtra('link', $e) ?: new stdClass(),
3268
                'selectable' => true,
3269
                'item_count' => 0,
3270
            ];
3271
        }
3272
3273
        // Drop empty virtual category, sort, and finalize UI hints
3274
        $catNodes = array_values(array_filter($cats, static function ($c) {
3275
            if (!empty($c['_virtual']) && empty($c['items'])) {
3276
                return false;
3277
            }
3278
3279
            return true;
3280
        }));
3281
3282
        foreach ($catNodes as &$c) {
3283
            if (!empty($c['items'])) {
3284
                usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3285
            }
3286
            $c['has_children'] = !empty($c['items']);
3287
            $c['item_count'] = \count($c['items'] ?? []);
3288
        }
3289
        unset($c);
3290
3291
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3292
3293
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
3294
3295
        return [
3296
            'type' => 'link',
3297
            'title' => $groupTitle,
3298
            'items' => $catNodes,
3299
        ];
3300
    }
3301
3302
    /**
3303
     * Leaves only the items selected by the UI in $course->resources.
3304
     * Expects $selected with the following form:
3305
     * [
3306
     * "documents" => ["123" => true, "124" => true],
3307
     * "links" => ["7" => true],
3308
     * "quiz" => ["45" => true],
3309
     * ...
3310
     * ].
3311
     */
3312
    private function filterCourseResources(object $course, array $selected): void
3313
    {
3314
        if (!isset($course->resources) || !\is_array($course->resources)) {
3315
            return;
3316
        }
3317
3318
        $typeMap = [
3319
            'documents' => RESOURCE_DOCUMENT,
3320
            'links' => RESOURCE_LINK,
3321
            'quizzes' => RESOURCE_QUIZ,
3322
            'quiz' => RESOURCE_QUIZ,
3323
            'quiz_questions' => RESOURCE_QUIZQUESTION,
3324
            'surveys' => RESOURCE_SURVEY,
3325
            'survey' => RESOURCE_SURVEY,
3326
            'survey_questions' => RESOURCE_SURVEYQUESTION,
3327
            'announcements' => RESOURCE_ANNOUNCEMENT,
3328
            'events' => RESOURCE_EVENT,
3329
            'course_description' => RESOURCE_COURSEDESCRIPTION,
3330
            'glossary' => RESOURCE_GLOSSARY,
3331
            'wiki' => RESOURCE_WIKI,
3332
            'thematic' => RESOURCE_THEMATIC,
3333
            'attendance' => RESOURCE_ATTENDANCE,
3334
            'works' => RESOURCE_WORK,
3335
            'gradebook' => RESOURCE_GRADEBOOK,
3336
            'learnpaths' => RESOURCE_LEARNPATH,
3337
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
3338
            'tool_intro' => RESOURCE_TOOL_INTRO,
3339
            'forums' => RESOURCE_FORUM,
3340
            'forum' => RESOURCE_FORUM,
3341
            'forum_topic' => RESOURCE_FORUMTOPIC,
3342
            'forum_post' => RESOURCE_FORUMPOST,
3343
        ];
3344
3345
        $allowed = [];
3346
        foreach ($selected as $k => $idsMap) {
3347
            $key = $typeMap[$k] ?? $k;
3348
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
3349
        }
3350
3351
        foreach ($course->resources as $rtype => $bucket) {
3352
            if (!isset($allowed[$rtype])) {
3353
                continue;
3354
            }
3355
            $keep = $allowed[$rtype];
3356
            $filtered = [];
3357
            foreach ((array) $bucket as $id => $obj) {
3358
                $iid = (int) ($obj->source_id ?? $id);
3359
                if (isset($keep[$iid])) {
3360
                    $filtered[$id] = $obj;
3361
                }
3362
            }
3363
            $course->resources[$rtype] = $filtered;
3364
        }
3365
    }
3366
3367
    /**
3368
     * Resolve absolute path of a backupId inside the backups directory, with safety checks.
3369
     */
3370
    private function resolveBackupPath(string $backupId): string
3371
    {
3372
        $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR);
3373
        $baseReal = realpath($base) ?: $base;
3374
3375
        $file = basename($backupId);
3376
        $path = $baseReal.DIRECTORY_SEPARATOR.$file;
3377
3378
        $real = realpath($path);
3379
3380
        if (false !== $real && 0 === strncmp($real, $baseReal, \strlen($baseReal))) {
3381
            return $real;
3382
        }
3383
3384
        return $path;
3385
    }
3386
3387
    /**
3388
     * Load a legacy Course object from any backup:
3389
     * - Chamilo (.zip with course_info.dat) → CourseArchiver::readCourse() or lenient fallback (your original logic)
3390
     * - Moodle (.mbz/.tgz/.gz or ZIP with moodle_backup.xml) → MoodleImport builder
3391
     *
3392
     * IMPORTANT:
3393
     * - Keeps your original Chamilo flow intact (strict → fallback manual decode/unserialize).
3394
     * - Tries Moodle only when the package looks like Moodle.
3395
     * - Adds __meta.import_source = "chamilo" | "moodle" for downstream logic.
3396
     */
3397
    private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object
3398
    {
3399
        $path = $this->resolveBackupPath($backupId);
3400
3401
        $force = strtolower($force);
3402
        if ('dat' === $force || 'chamilo' === $force) {
3403
            $looksMoodle = false;
3404
            $preferChamilo = true;
3405
        } elseif ('moodle' === $force) {
3406
            $looksMoodle = true;
3407
            $preferChamilo = false;
3408
        } else {
3409
            $looksMoodle = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path);
3410
            $preferChamilo = $this->zipHasCourseInfoDat($path);
3411
        }
3412
3413
        if ($preferChamilo || !$looksMoodle) {
3414
            CourseArchiver::setDebug($this->debug);
3415
3416
            try {
3417
                $course = CourseArchiver::readCourse($backupId, false);
3418
                if (\is_object($course)) {
3419
                    // … (resto igual)
3420
                    if (!isset($course->resources) || !\is_array($course->resources)) {
3421
                        $course->resources = [];
3422
                    }
3423
                    $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3424
                    $course->resources['__meta']['import_source'] = 'chamilo';
3425
3426
                    return $course;
3427
                }
3428
            } catch (Throwable $e) {
3429
                $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]);
3430
            }
3431
3432
            $zipPath = $this->resolveBackupPath($backupId);
3433
            $ci = $this->readCourseInfoFromZip($zipPath);
3434
            if (empty($ci['ok'])) {
3435
                if ($looksMoodle) {
3436
                    $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort');
3437
3438
                    return $this->loadMoodleCourseOrFail($path);
3439
                }
3440
3441
                throw new RuntimeException('course_info.dat not found in backup');
3442
            }
3443
3444
            $raw = (string) $ci['data'];
3445
            $payload = base64_decode($raw, true);
3446
            if (false === $payload) {
3447
                $payload = $raw;
3448
            }
3449
3450
            $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload);
3451
            CourseArchiver::ensureLegacyAliases();
3452
3453
            set_error_handler(static function (): void {});
3454
3455
            try {
3456
                if (class_exists(UnserializeApi::class)) {
3457
                    $c = UnserializeApi::unserialize('course', $payload);
3458
                } else {
3459
                    $c = @unserialize($payload, ['allowed_classes' => true]);
3460
                }
3461
            } finally {
3462
                restore_error_handler();
3463
            }
3464
3465
            if (!\is_object($c ?? null)) {
3466
                if ($looksMoodle) {
3467
                    $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo fallback failed, trying MoodleImport');
3468
3469
                    return $this->loadMoodleCourseOrFail($path);
3470
                }
3471
3472
                throw new RuntimeException('Could not unserialize course (fallback)');
3473
            }
3474
3475
            if (!isset($c->resources) || !\is_array($c->resources)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $c does not seem to be defined for all execution paths leading up to this point.
Loading history...
3476
                $c->resources = [];
3477
            }
3478
            $c->resources['__meta'] = (array) ($c->resources['__meta'] ?? []);
3479
            $c->resources['__meta']['import_source'] = 'chamilo';
3480
3481
            return $c;
3482
        }
3483
3484
        // Moodle path
3485
        if ($looksMoodle) {
3486
            $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
3487
3488
            return $this->loadMoodleCourseOrFail($path);
3489
        }
3490
3491
        throw new RuntimeException('Unsupported package: neither course_info.dat nor moodle_backup.xml found.');
3492
    }
3493
3494
    /**
3495
     * Normalize resource buckets to the exact keys supported by CourseRestorer.
3496
     * Only the canonical keys below are produced; common aliases are mapped.
3497
     * - Never drop data: merge buckets; keep __meta as-is.
3498
     * - Make sure "document" survives if it existed before.
3499
     */
3500
    private function normalizeBucketsForRestorer(object $course): void
3501
    {
3502
        if (!isset($course->resources) || !\is_array($course->resources)) {
3503
            return;
3504
        }
3505
3506
        // Split meta buckets
3507
        $all = $course->resources;
3508
        $meta = [];
3509
        foreach ($all as $k => $v) {
3510
            if (\is_string($k) && str_starts_with($k, '__')) {
3511
                $meta[$k] = $v;
3512
                unset($all[$k]);
3513
            }
3514
        }
3515
3516
        // Start from current
3517
        $out = $all;
3518
3519
        // merge array buckets preserving numeric/string ids
3520
        $merge = static function (array $dst, array $src): array {
3521
            foreach ($src as $id => $obj) {
3522
                if (!\array_key_exists($id, $dst)) {
3523
                    $dst[$id] = $obj;
3524
                }
3525
            }
3526
3527
            return $dst;
3528
        };
3529
3530
        // safe alias map (input -> canonical). Extend only if needed.
3531
        $aliases = [
3532
            // documents
3533
            'documents' => 'document',
3534
            'Document' => 'document',
3535
            'document ' => 'document',
3536
3537
            // tool intro
3538
            'tool introduction' => 'tool_intro',
3539
            'tool_introduction' => 'tool_intro',
3540
            'tool/introduction' => 'tool_intro',
3541
            'tool intro' => 'tool_intro',
3542
            'Tool introduction' => 'tool_intro',
3543
3544
            // forums
3545
            'forums' => 'forum',
3546
            'Forum' => 'forum',
3547
            'Forum_Category' => 'forum_category',
3548
            'forumcategory' => 'forum_category',
3549
            'thread' => 'forum_topic',
3550
            'Thread' => 'forum_topic',
3551
            'forumtopic' => 'forum_topic',
3552
            'post' => 'forum_post',
3553
            'Post' => 'forum_post',
3554
            'forumpost' => 'forum_post',
3555
3556
            // links
3557
            'links' => 'link',
3558
            'link category' => 'link_category',
3559
3560
            // quiz + questions
3561
            'Exercise_Question' => 'exercise_question',
3562
            'exercisequestion' => 'exercise_question',
3563
3564
            // surveys
3565
            'surveys' => 'survey',
3566
            'surveyquestion' => 'survey_question',
3567
3568
            // announcements
3569
            'announcements' => 'announcement',
3570
            'Announcements' => 'announcement',
3571
        ];
3572
3573
        // Normalize keys (case/spacing) and apply alias merges
3574
        foreach ($all as $rawKey => $_bucket) {
3575
            if (!\is_array($_bucket)) {
3576
                continue; // defensive
3577
            }
3578
            $k = (string) $rawKey;
3579
            $norm = strtolower(trim(strtr($k, ['\\' => '/', '-' => '_'])));
3580
            $norm2 = str_replace('/', '_', $norm);
3581
3582
            $canonical = null;
3583
            if (isset($aliases[$norm])) {
3584
                $canonical = $aliases[$norm];
3585
            } elseif (isset($aliases[$norm2])) {
3586
                $canonical = $aliases[$norm2];
3587
            }
3588
3589
            if ($canonical && $canonical !== $rawKey) {
3590
                // Merge into canonical and drop the alias key
3591
                $out[$canonical] = isset($out[$canonical]) && \is_array($out[$canonical])
3592
                    ? $merge($out[$canonical], $_bucket)
3593
                    : $_bucket;
3594
                unset($out[$rawKey]);
3595
            }
3596
            // else: leave as-is (pass-through)
3597
        }
3598
3599
        // Safety: if there was any docs bucket under an alias, ensure 'document' is present.
3600
        if (!isset($out['document'])) {
3601
            if (isset($all['documents']) && \is_array($all['documents'])) {
3602
                $out['document'] = $all['documents'];
3603
            } elseif (isset($all['Document']) && \is_array($all['Document'])) {
3604
                $out['document'] = $all['Document'];
3605
            }
3606
        }
3607
3608
        // Gentle ordering for readability only (does not affect presence)
3609
        $order = [
3610
            'announcement', 'document', 'link', 'link_category',
3611
            'forum', 'forum_category', 'forum_topic', 'forum_post',
3612
            'quiz', 'exercise_question',
3613
            'survey', 'survey_question',
3614
            'learnpath', 'tool_intro',
3615
            'work',
3616
        ];
3617
        $w = [];
3618
        foreach ($order as $i => $key) {
3619
            $w[$key] = $i;
3620
        }
3621
        uksort($out, static function ($a, $b) use ($w) {
3622
            $wa = $w[$a] ?? 9999;
3623
            $wb = $w[$b] ?? 9999;
3624
3625
            return $wa <=> $wb ?: strcasecmp((string) $a, (string) $b);
3626
        });
3627
3628
        // Final assign: meta first, then normalized buckets
3629
        $course->resources = $meta + $out;
3630
3631
        // Debug trace to verify we didn't lose keys
3632
        $this->logDebug('[normalizeBucketsForRestorer] final keys', array_keys((array) $course->resources));
3633
    }
3634
3635
    /**
3636
     * Read import_source without depending on filtered resources.
3637
     * Falls back to $course->info['__import_source'] if needed.
3638
     */
3639
    private function getImportSource(object $course): string
3640
    {
3641
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
3642
        if ('' !== $src) {
3643
            return $src;
3644
        }
3645
3646
        // Fallbacks (defensive)
3647
        return strtolower((string) ($course->info['__import_source'] ?? ''));
3648
    }
3649
3650
    /**
3651
     * Builds a CC 1.3 preview from the legacy Course (only the implemented one).
3652
     * Returns a structure intended for rendering/committing before the actual export.
3653
     */
3654
    private function buildCc13Preview(object $course): array
0 ignored issues
show
Unused Code introduced by
The method buildCc13Preview() 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...
3655
    {
3656
        $ims = [
3657
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document']
3658
            'resources' => [
3659
                'webcontent' => [],
3660
            ],
3661
            'counts' => ['files' => 0, 'folders' => 0],
3662
            'defaultSelection' => [
3663
                'documents' => [],
3664
            ],
3665
        ];
3666
3667
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3668
        $docKey = null;
3669
3670
        foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) {
3671
            if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) {
3672
                $docKey = $cand;
3673
3674
                break;
3675
            }
3676
        }
3677
        if (!$docKey) {
3678
            return $ims;
3679
        }
3680
3681
        foreach ($res[$docKey] as $iid => $wrap) {
3682
            if (!\is_object($wrap)) {
3683
                continue;
3684
            }
3685
            $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3686
3687
            $rawPath = (string) ($e->path ?? $e->full_path ?? '');
3688
            if ('' === $rawPath) {
3689
                continue;
3690
            }
3691
            $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/');
3692
3693
            $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
3694
            $isDir = ('folder' === $fileType) || ('/' === substr($rawPath, -1));
3695
3696
            $title = (string) ($e->title ?? $wrap->name ?? basename($rel));
3697
            $ims['resources']['webcontent'][] = [
3698
                'id' => (int) $iid,
3699
                'cc_type' => 'webcontent',
3700
                'title' => '' !== $title ? $title : basename($rel),
3701
                'rel' => $rel,
3702
                'is_dir' => $isDir,
3703
                'would_be_manifest_entry' => !$isDir,
3704
            ];
3705
3706
            if (!$isDir) {
3707
                $ims['defaultSelection']['documents'][(int) $iid] = true;
3708
                $ims['counts']['files']++;
3709
            } else {
3710
                $ims['counts']['folders']++;
3711
            }
3712
        }
3713
3714
        return $ims;
3715
    }
3716
3717
    /**
3718
     * Expand category selections (link/forum) to their item IDs using a full course snapshot.
3719
     * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel.
3720
     *
3721
     * @return array<string, array<string, bool>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, array<string, bool> at position 9 could not be parsed: Expected '>' at position 9, but found '>'.
Loading history...
3722
     */
3723
    private function expandCc13SelectionFromCategories(object $course, array $normSel): array
3724
    {
3725
        $out = [
3726
            'documents' => (array) ($normSel['documents'] ?? []),
3727
            'links' => (array) ($normSel['links'] ?? []),
3728
            'forums' => (array) ($normSel['forums'] ?? []),
3729
        ];
3730
3731
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3732
3733
        // Link categories → link IDs
3734
        if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) {
3735
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true);
3736
            $links = $res['link'] ?? $res['Link'];
3737
            foreach ($links as $lid => $wrap) {
3738
                if (!\is_object($wrap)) {
3739
                    continue;
3740
                }
3741
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3742
                $cid = (string) (int) ($e->category_id ?? 0);
3743
                if (isset($selCats[$cid])) {
3744
                    $out['links'][(string) $lid] = true;
3745
                }
3746
            }
3747
        }
3748
3749
        // Forum categories → forum IDs
3750
        if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) {
3751
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true);
3752
            $forums = $res['forum'] ?? $res['Forum'];
3753
            foreach ($forums as $fid => $wrap) {
3754
                if (!\is_object($wrap)) {
3755
                    continue;
3756
                }
3757
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3758
                $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
3759
                if (isset($selCats[$cid])) {
3760
                    $out['forums'][(string) $fid] = true;
3761
                }
3762
            }
3763
        }
3764
3765
        return $out;
3766
    }
3767
3768
    /**
3769
     * Infer tool buckets required by a given selection payload (used in 'selected' scope).
3770
     *
3771
     * Expected selection items like: { "type": "document"|"quiz"|"survey"|... , "id": <int> }
3772
     *
3773
     * @param array<int,array<string,mixed>> $selected
3774
     *
3775
     * @return string[]
3776
     */
3777
    private function inferToolsFromSelection(array $selected): array
3778
    {
3779
        $has = static fn (string $k): bool => !empty($selected[$k]) && \is_array($selected[$k]) && \count($selected[$k]) > 0;
3780
3781
        $want = [];
3782
3783
        // documents
3784
        if ($has('document')) {
3785
            $want[] = 'documents';
3786
        }
3787
3788
        // links (categories imply links too)
3789
        if ($has('link') || $has('link_category')) {
3790
            $want[] = 'links';
3791
        }
3792
3793
        // forums (any of the family implies forums)
3794
        if ($has('forum') || $has('forum_category') || $has('forum_topic') || $has('thread') || $has('post') || $has('forum_post')) {
3795
            $want[] = 'forums';
3796
        }
3797
3798
        // quizzes / questions
3799
        if ($has('quiz') || $has('exercise') || $has('exercise_question')) {
3800
            $want[] = 'quizzes';
3801
            $want[] = 'quiz_questions';
3802
        }
3803
3804
        // surveys / questions / invitations
3805
        if ($has('survey') || $has('survey_question') || $has('survey_invitation')) {
3806
            $want[] = 'surveys';
3807
            $want[] = 'survey_questions';
3808
        }
3809
3810
        // learnpaths
3811
        if ($has('learnpath') || $has('learnpath_category')) {
3812
            $want[] = 'learnpaths';
3813
            $want[] = 'learnpath_category';
3814
        }
3815
3816
        // others
3817
        if ($has('work')) {
3818
            $want[] = 'works';
3819
        }
3820
        if ($has('glossary')) {
3821
            $want[] = 'glossary';
3822
        }
3823
        if ($has('tool_intro')) {
3824
            $want[] = 'tool_intro';
3825
        }
3826
        if ($has('course_descriptions') || $has('course_description')) {
3827
            $tools[] = 'course_descriptions';
0 ignored issues
show
Comprehensibility Best Practice introduced by
$tools was never initialized. Although not strictly required by PHP, it is generally a good practice to add $tools = array(); before regardless.
Loading history...
3828
        }
3829
3830
        // Dedup
3831
        return array_values(array_unique(array_filter($want)));
3832
    }
3833
3834
    private function intersectBucketByIds(array $bucket, array $idsMap): array
3835
    {
3836
        $out = [];
3837
        foreach ($bucket as $id => $obj) {
3838
            $ent = (isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3839
            $k1 = (string) $id;
3840
            $k2 = (string) ($ent->source_id ?? $obj->source_id ?? '');
3841
            if (isset($idsMap[$k1]) || ('' !== $k2 && isset($idsMap[$k2]))) {
3842
                $out[$id] = $obj;
3843
            }
3844
        }
3845
3846
        return $out;
3847
    }
3848
3849
    private function bucketKeyCandidates(string $type): array
3850
    {
3851
        $t = $this->normalizeTypeKey($type);
3852
3853
        // Constants (string values) if defined
3854
        $RD = \defined('RESOURCE_DOCUMENT') ? (string) RESOURCE_DOCUMENT : '';
3855
        $RL = \defined('RESOURCE_LINK') ? (string) RESOURCE_LINK : '';
3856
        $RF = \defined('RESOURCE_FORUM') ? (string) RESOURCE_FORUM : '';
3857
        $RFT = \defined('RESOURCE_FORUMTOPIC') ? (string) RESOURCE_FORUMTOPIC : '';
3858
        $RFP = \defined('RESOURCE_FORUMPOST') ? (string) RESOURCE_FORUMPOST : '';
3859
        $RQ = \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : '';
3860
        $RQQ = \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : '';
3861
        $RS = \defined('RESOURCE_SURVEY') ? (string) RESOURCE_SURVEY : '';
3862
        $RSQ = \defined('RESOURCE_SURVEYQUESTION') ? (string) RESOURCE_SURVEYQUESTION : '';
3863
3864
        $map = [
3865
            'document' => ['document', 'Document', $RD],
3866
            'link' => ['link', 'Link', $RL],
3867
            'link_category' => ['link_category', 'Link_Category'],
3868
            'forum' => ['forum', 'Forum', $RF],
3869
            'forum_category' => ['forum_category', 'Forum_Category'],
3870
            'forum_topic' => ['forum_topic', 'thread', $RFT],
3871
            'forum_post' => ['forum_post', 'post', $RFP],
3872
            'quiz' => ['quiz', 'Quiz', $RQ],
3873
            'exercise_question' => ['Exercise_Question', 'exercise_question', $RQQ],
3874
            'survey' => ['survey', 'Survey', $RS],
3875
            'survey_question' => ['Survey_Question', 'survey_question', $RSQ],
3876
            'tool_intro' => ['tool_intro', 'Tool introduction'],
3877
        ];
3878
3879
        $c = $map[$t] ?? [$t, ucfirst($t)];
3880
3881
        return array_values(array_filter($c, static fn ($x) => '' !== $x));
3882
    }
3883
3884
    private function findBucketKey(array $res, string $type): ?string
3885
    {
3886
        $key = $this->firstExistingKey($res, $this->bucketKeyCandidates($type));
3887
3888
        return null !== $key ? (string) $key : null;
3889
    }
3890
3891
    private function findBucket(array $res, string $type): array
3892
    {
3893
        $k = $this->findBucketKey($res, $type);
3894
3895
        return (null !== $k && isset($res[$k]) && \is_array($res[$k])) ? $res[$k] : [];
3896
    }
3897
3898
    /**
3899
     * True if file extension suggests a Moodle backup.
3900
     */
3901
    private function isMoodleByExt(string $path): bool
3902
    {
3903
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3904
3905
        return \in_array($ext, ['mbz', 'tgz', 'gz'], true);
3906
    }
3907
3908
    /**
3909
     * Quick ZIP probe for 'moodle_backup.xml'. Safe no-op for non-zip files.
3910
     */
3911
    private function zipHasMoodleBackupXml(string $path): bool
3912
    {
3913
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3914
        // Many .mbz are plain ZIPs; try to open if extension is zip/mbz
3915
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3916
            return false;
3917
        }
3918
        $zip = new ZipArchive();
3919
        if (true !== ($err = $zip->open($path))) {
3920
            return false;
3921
        }
3922
        $idx = $zip->locateName('moodle_backup.xml', ZipArchive::FL_NOCASE);
3923
        $zip->close();
3924
3925
        return false !== $idx;
3926
    }
3927
3928
    /**
3929
     * Quick ZIP probe for 'course_info.dat'. Safe no-op for non-zip files.
3930
     */
3931
    private function zipHasCourseInfoDat(string $path): bool
3932
    {
3933
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3934
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3935
            return false;
3936
        }
3937
        $zip = new ZipArchive();
3938
        if (true !== ($err = $zip->open($path))) {
3939
            return false;
3940
        }
3941
        // common locations
3942
        foreach (['course_info.dat', 'course/course_info.dat', 'backup/course_info.dat'] as $cand) {
3943
            $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
3944
            if (false !== $idx) {
3945
                $zip->close();
3946
3947
                return true;
3948
            }
3949
        }
3950
        $zip->close();
3951
3952
        return false;
3953
    }
3954
3955
    /**
3956
     * Build legacy Course graph from a Moodle archive and set __meta.import_source.
3957
     * Throws RuntimeException on failure.
3958
     */
3959
    private function loadMoodleCourseOrFail(string $absPath): object
3960
    {
3961
        if (!class_exists(MoodleImport::class)) {
3962
            throw new RuntimeException('MoodleImport class not available');
3963
        }
3964
        $importer = new MoodleImport(debug: $this->debug);
3965
3966
        if (!method_exists($importer, 'buildLegacyCourseFromMoodleArchive')) {
3967
            throw new RuntimeException('MoodleImport::buildLegacyCourseFromMoodleArchive() not available');
3968
        }
3969
3970
        $course = $importer->buildLegacyCourseFromMoodleArchive($absPath);
3971
3972
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
3973
            throw new RuntimeException('Moodle backup contains no importable resources');
3974
        }
3975
3976
        $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3977
        $course->resources['__meta']['import_source'] = 'moodle';
3978
3979
        return $course;
3980
    }
3981
3982
    /**
3983
     * Recursively sanitize an unserialized PHP graph:
3984
     * - Objects are cast to arrays, keys like "\0Class\0prop" become "prop"
3985
     * - Returns arrays/stdClass with only public-like keys
3986
     */
3987
    private function sanitizePhpGraph(mixed $value): mixed
3988
    {
3989
        if (\is_array($value)) {
3990
            $out = [];
3991
            foreach ($value as $k => $v) {
3992
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
3993
                $out[$ck] = $this->sanitizePhpGraph($v);
3994
            }
3995
3996
            return $out;
3997
        }
3998
3999
        if (\is_object($value)) {
4000
            $arr = (array) $value;
4001
            $clean = [];
4002
            foreach ($arr as $k => $v) {
4003
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
4004
                $clean[$ck] = $this->sanitizePhpGraph($v);
4005
            }
4006
4007
            return (object) $clean;
4008
        }
4009
4010
        return $value;
4011
    }
4012
}
4013