Passed
Pull Request — master (#6921)
by
unknown
09:03
created

normalizeBucketsForRestorer()   B

Complexity

Conditions 7
Paths 5

Size

Total Lines 34
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

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

492
            error_log('$legacyCourse :::: './** @scrutinizer ignore-type */ print_r($legacyCourse, true));
Loading history...
493
494
            $restorer = new CourseRestorer($legacyCourse);
495
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
496
            if (method_exists($restorer, 'setDebug')) {
497
                $restorer->setDebug($this->debug);
498
            }
499
            $restorer->restore();
500
501
            $dest = api_get_course_info();
502
            $redirectUrl = \sprintf('/course/%d/home', (int) $dest['real_id']);
503
504
            return $this->json([
505
                'ok' => true,
506
                'message' => 'Copy finished',
507
                'redirectUrl' => $redirectUrl,
508
            ]);
509
        } catch (Throwable $e) {
510
            return $this->json([
511
                'error' => 'Copy failed: '.$e->getMessage(),
512
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
513
            ], 500);
514
        }
515
    }
516
517
    #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])]
518
    public function recycleOptions(int $node, Request $req): JsonResponse
519
    {
520
        $this->setDebugFromRequest($req);
521
522
        // current course only
523
        $defaults = [
524
            'recycleOption' => 'select_items', // 'full_recycle' | 'select_items'
525
            'confirmNeeded' => true,           // show code-confirm input when full
526
        ];
527
528
        return $this->json(['defaults' => $defaults]);
529
    }
530
531
    #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])]
532
    public function recycleResources(int $node, Request $req): JsonResponse
533
    {
534
        $this->setDebugFromRequest($req);
535
536
        // Build legacy Course from CURRENT course (not “source”)
537
        $cb = new CourseBuilder();
538
        $cb->set_tools_to_build([
539
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
540
            'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki',
541
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
542
        ]);
543
        $course = $cb->build(0, api_get_course_id());
544
545
        $tree = $this->buildResourceTreeForVue($course);
546
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
547
548
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
549
    }
550
551
    #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])]
552
    public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse
553
    {
554
        try {
555
            $p = json_decode($req->getContent() ?: '{}', true);
556
            $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items'
557
            $resourcesMap = (array) ($p['resources'] ?? []);
558
            $confirmCode = (string) ($p['confirm'] ?? '');
559
560
            $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items';
561
562
            if ('full_backup' === $type) {
563
                if ($confirmCode !== api_get_course_id()) {
564
                    return $this->json(['error' => 'Course code confirmation mismatch'], 400);
565
                }
566
            } else {
567
                if (empty($resourcesMap)) {
568
                    return $this->json(['error' => 'No resources selected'], 400);
569
                }
570
            }
571
572
            $courseCode = api_get_course_id();
573
            $courseInfo = api_get_course_info($courseCode);
574
            $courseId = (int) ($courseInfo['real_id'] ?? 0);
575
            if ($courseId <= 0) {
576
                return $this->json(['error' => 'Invalid course id'], 400);
577
            }
578
579
            $recycler = new CourseRecycler(
580
                $em,
581
                $courseCode,
582
                $courseId
583
            );
584
585
            $recycler->recycle($type, $resourcesMap);
586
587
            return $this->json([
588
                'ok' => true,
589
                'message' => 'Recycle finished',
590
            ]);
591
        } catch (Throwable $e) {
592
            return $this->json([
593
                'error' => 'Recycle failed: '.$e->getMessage(),
594
            ], 500);
595
        }
596
    }
597
598
    #[Route('/delete', name: 'delete', methods: ['POST'])]
599
    public function deleteCourse(int $node, Request $req): JsonResponse
600
    {
601
        // Basic permission gate (adjust roles to your policy if needed)
602
        if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_TEACHER') && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')) {
603
            return $this->json(['error' => 'You are not allowed to delete this course'], 403);
604
        }
605
606
        try {
607
            $payload = json_decode($req->getContent() ?: '{}', true);
608
            $confirm = trim((string) ($payload['confirm'] ?? ''));
609
610
            if ('' === $confirm) {
611
                return $this->json(['error' => 'Missing confirmation value'], 400);
612
            }
613
614
            // Current course
615
            $courseInfo = api_get_course_info();
616
            if (empty($courseInfo)) {
617
                return $this->json(['error' => 'Unable to resolve current course'], 400);
618
            }
619
620
            $officialCode = (string) ($courseInfo['official_code'] ?? '');
621
            $runtimeCode = (string) api_get_course_id();                 // often equals official code
622
            $sysCode = (string) ($courseInfo['sysCode'] ?? '');       // used by legacy delete
623
624
            if ('' === $sysCode) {
625
                return $this->json(['error' => 'Invalid course system code'], 400);
626
            }
627
628
            // Accept either official_code or api_get_course_id() as confirmation
629
            $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm);
630
            if (!$matches) {
631
                return $this->json(['error' => 'Course code confirmation mismatch'], 400);
632
            }
633
634
            // Legacy delete (removes course data + unregisters members in this course)
635
            // Throws on failure or returns void
636
            CourseManager::delete_course($sysCode);
637
638
            // Best-effort cleanup of legacy course session flags
639
            try {
640
                $ses = $req->getSession();
641
                $ses?->remove('_cid');
642
                $ses?->remove('_real_cid');
643
            } catch (Throwable) {
644
                // swallow — not critical
645
            }
646
647
            // Decide where to send the user afterwards
648
            // You can use '/index.php' or a landing page
649
            $redirectUrl = '/index.php';
650
651
            return $this->json([
652
                'ok' => true,
653
                'message' => 'Course deleted successfully',
654
                'redirectUrl' => $redirectUrl,
655
            ]);
656
        } catch (Throwable $e) {
657
            return $this->json([
658
                'error' => 'Failed to delete course: '.$e->getMessage(),
659
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
660
            ], 500);
661
        }
662
    }
663
664
    #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])]
665
    public function moodleExportOptions(int $node, Request $req, UserRepository $users): JsonResponse
666
    {
667
        $defaults = [
668
            'moodleVersion' => '4',
669
            'scope' => 'full',
670
            'admin' => $users->getDefaultAdminForExport(),
671
        ];
672
673
        return $this->json([
674
            'versions' => [
675
                ['value' => '3', 'label' => 'Moodle 3.x'],
676
                ['value' => '4', 'label' => 'Moodle 4.x'],
677
            ],
678
            'defaults' => $defaults,
679
        ]);
680
    }
681
682
    #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])]
683
    public function moodleExportResources(int $node, Request $req): JsonResponse
684
    {
685
        $this->setDebugFromRequest($req);
686
687
        // Build legacy Course from CURRENT course (same approach as recycle)
688
        $cb = new CourseBuilder();
689
        $cb->set_tools_to_build([
690
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
691
            'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki',
692
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
693
        ]);
694
        $course = $cb->build(0, api_get_course_id());
695
696
        $tree = $this->buildResourceTreeForVue($course);
697
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
698
699
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
700
    }
701
702
    #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])]
703
    public function moodleExportExecute(int $node, Request $req, UserRepository $users): JsonResponse|BinaryFileResponse
704
    {
705
        $this->setDebugFromRequest($req);
706
707
        $p = json_decode($req->getContent() ?: '{}', true);
708
        $moodleVersion = (string) ($p['moodleVersion'] ?? '4');   // '3' | '4'
709
        $scope = (string) ($p['scope'] ?? 'full');        // 'full' | 'selected'
710
        $adminId = (int) ($p['adminId'] ?? 0);
711
        $adminLogin = trim((string) ($p['adminLogin'] ?? ''));
712
        $adminEmail = trim((string) ($p['adminEmail'] ?? ''));
713
        $selected = (array) ($p['resources'] ?? []);
714
715
        if (!\in_array($moodleVersion, ['3', '4'], true)) {
716
            return $this->json(['error' => 'Unsupported Moodle version'], 400);
717
        }
718
        if ('selected' === $scope && empty($selected)) {
719
            return $this->json(['error' => 'No resources selected'], 400);
720
        }
721
722
        if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
723
            $adm = $users->getDefaultAdminForExport();
724
            $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
725
            $adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
726
            $adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? '[email protected]');
727
        }
728
729
        // Build legacy Course from CURRENT course (same approach as recycle)
730
        $cb = new CourseBuilder();
731
        $cb->set_tools_to_build([
732
            'documents', 'links', 'quizzes', 'quiz_questions', 'surveys', 'survey_questions',
733
            'announcements', 'events', 'course_descriptions', 'glossary', 'wiki', 'thematic',
734
            'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', 'tool_intro',
735
            'forums',
736
        ]);
737
        $course = $cb->build(0, api_get_course_id());
738
739
        // IMPORTANT: when scope === 'selected', use the same robust selection filter as copy-course
740
        if ('selected' === $scope) {
741
            // This method trims buckets to only selected items and pulls needed deps (LP/quiz/survey)
742
            $course = $this->filterLegacyCourseBySelection($course, $selected);
743
744
            // Safety guard: fail if nothing remains after filtering
745
            if (empty($course->resources) || !\is_array($course->resources)) {
746
                return $this->json(['error' => 'Selection produced no resources to export'], 400);
747
            }
748
        }
749
750
        try {
751
            // Pass selection flag to exporter so it does NOT re-hydrate from a complete snapshot.
752
            $selectionMode = ('selected' === $scope);
753
            $exporter = new MoodleExport($course, $selectionMode);
754
            $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail);
755
756
            $courseId = api_get_course_id();
757
            $exportDir = 'moodle_export_'.date('Ymd_His');
758
            $versionNum = ('3' === $moodleVersion) ? 3 : 4;
759
760
            $mbzPath = $exporter->export($courseId, $exportDir, $versionNum);
761
762
            $resp = new BinaryFileResponse($mbzPath);
763
            $resp->setContentDisposition(
764
                ResponseHeaderBag::DISPOSITION_ATTACHMENT,
765
                basename($mbzPath)
766
            );
767
768
            return $resp;
769
        } catch (Throwable $e) {
770
            return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
771
        }
772
    }
773
774
    #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])]
775
    public function cc13ExportOptions(int $node, Request $req): JsonResponse
776
    {
777
        $this->setDebugFromRequest($req);
778
779
        return $this->json([
780
            'defaults' => ['scope' => 'full'],
781
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
782
            'message' => 'Common Cartridge 1.3: documents (webcontent) and links (HTML stub as webcontent). Forums not exported yet.',
783
        ]);
784
    }
785
786
    #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])]
787
    public function cc13ExportResources(int $node, Request $req): JsonResponse
788
    {
789
        $this->setDebugFromRequest($req);
790
791
        $cb = new CourseBuilder();
792
        $cb->set_tools_to_build(['documents', 'links', 'forums']);
793
        $course = $cb->build(0, api_get_course_id());
794
795
        $treeAll = $this->buildResourceTreeForVue($course);
796
        $tree    = Cc13Capabilities::filterTree($treeAll);
797
798
        // Count exportables using "items"
799
        $exportableCount = 0;
800
        foreach ($tree as $group) {
801
            if (empty($group['items']) || !\is_array($group['items'])) { continue; }
802
803
            if (($group['type'] ?? '') === 'forum') {
804
                foreach ($group['items'] as $cat) {
805
                    foreach (($cat['items'] ?? []) as $forumNode) {
806
                        if (($forumNode['type'] ?? '') === 'forum') { $exportableCount++; }
807
                    }
808
                }
809
            } else {
810
                $exportableCount += \count($group['items'] ?? []);
811
            }
812
        }
813
814
        $warnings = [];
815
        if ($exportableCount === 0) {
816
            $warnings[] = 'This course has no CC 1.3 exportable resources (documents, links or forums).';
817
        }
818
819
        return $this->json([
820
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
821
            'tree'           => $tree,
822
            'preview'        => ['counts' => ['total' => $exportableCount]],
823
            'warnings'       => $warnings,
824
        ]);
825
    }
826
827
    #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])]
828
    public function cc13ExportExecute(int $node, Request $req): JsonResponse
829
    {
830
        $payload  = json_decode((string) $req->getContent(), true) ?: [];
831
        // If the client sent "resources", treat as selected even if scope says "full".
832
        $scope    = (string) ($payload['scope'] ?? (!empty($payload['resources']) ? 'selected' : 'full'));
833
        $selected = (array)  ($payload['resources'] ?? []);
834
835
        // Normalize selection structure (documents/links/forums/…)
836
        $normSel = Cc13Capabilities::filterSelection($selected);
837
838
        // Builder setup
839
        $tools = ['documents', 'links', 'forums'];
840
        $cb    = new CourseBuilder();
841
842
        $selectionMode = false;
843
844
        try {
845
            if ($scope === 'selected') {
846
                // Build a full snapshot first to expand any category-only selections.
847
                $cbFull = new CourseBuilder();
848
                $cbFull->set_tools_to_build($tools);
849
                $courseFull = $cbFull->build(0, api_get_course_id());
850
851
                $expanded = $this->expandCc13SelectionFromCategories($courseFull, $normSel);
852
853
                // Build per-tool ID map for CourseBuilder
854
                $map = [];
855
                if (!empty($expanded['documents'])) { $map['documents'] = array_map('intval', array_keys($expanded['documents'])); }
856
                if (!empty($expanded['links']))     { $map['links']     = array_map('intval', array_keys($expanded['links'])); }
857
                if (!empty($expanded['forums']))    { $map['forums']    = array_map('intval', array_keys($expanded['forums'])); }
858
859
                if (empty($map)) {
860
                    return $this->json(['error' => 'Please select at least one resource.'], 400);
861
                }
862
863
                $cb->set_tools_to_build($tools);
864
                $cb->set_tools_specific_id_list($map);
865
                $selectionMode = true;
866
            } else {
867
                $cb->set_tools_to_build($tools);
868
            }
869
870
            $course = $cb->build(0, api_get_course_id());
871
872
            // Safety net: if selection mode, ensure resources are filtered
873
            if ($selectionMode) {
874
                // Convert to the expected structure for filterCourseResources()
875
                $safeSelected = [
876
                    'documents' => array_fill_keys(array_map('intval', array_keys($normSel['documents'] ?? [])), true),
877
                    'links'     => array_fill_keys(array_map('intval', array_keys($normSel['links'] ?? [])), true),
878
                    'forums'    => array_fill_keys(array_map('intval', array_keys($normSel['forums'] ?? [])), true),
879
                ];
880
                // Also include expansions from categories
881
                $fullSnapshot = isset($courseFull) ? $courseFull : $course;
882
                $expandedAll  = $this->expandCc13SelectionFromCategories($fullSnapshot, $normSel);
883
                foreach (['documents','links','forums'] as $k) {
884
                    foreach (array_keys($expandedAll[$k] ?? []) as $idStr) {
885
                        $safeSelected[$k][(int)$idStr] = true;
886
                    }
887
                }
888
889
                $this->filterCourseResources($course, $safeSelected);
890
                if (empty($course->resources) || !\is_array($course->resources)) {
891
                    return $this->json(['error' => 'Nothing to export after filtering your selection.'], 400);
892
                }
893
            }
894
895
            $exporter  = new Cc13Export($course, $selectionMode, /*debug*/ false);
896
            $imsccPath = $exporter->export(api_get_course_id());
897
            $fileName  = basename($imsccPath);
898
899
            $downloadUrl = $this->generateUrl(
900
                    'cm_cc13_export_download',
901
                    ['node' => $node],
902
                    UrlGeneratorInterface::ABSOLUTE_URL
903
                ).'?file='.rawurlencode($fileName);
904
905
            return $this->json([
906
                'ok'          => true,
907
                'file'        => $fileName,
908
                'downloadUrl' => $downloadUrl,
909
                'message'     => 'Export finished.',
910
            ]);
911
        } catch (\RuntimeException $e) {
912
            if (stripos($e->getMessage(), 'Nothing to export') !== false) {
913
                return $this->json(['error' => 'Nothing to export (no compatible resources found).'], 400);
914
            }
915
            return $this->json(['error' => 'CC 1.3 export failed: '.$e->getMessage()], 500);
916
        }
917
    }
918
919
    #[Route('/cc13/export/download', name: 'cc13_export_download', methods: ['GET'])]
920
    public function cc13ExportDownload(int $node, Request $req): BinaryFileResponse|JsonResponse
921
    {
922
        // Validate the filename we will serve
923
        $file = basename((string) $req->query->get('file', ''));
924
        // Example pattern: ABC123_cc13_20251017_195455.imscc
925
        if ($file === '' || !preg_match('/^[A-Za-z0-9_-]+_cc13_\d{8}_\d{6}\.imscc$/', $file)) {
926
            return $this->json(['error' => 'Invalid file'], 400);
927
        }
928
929
        $abs = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $file;
930
        if (!is_file($abs)) {
931
            return $this->json(['error' => 'File not found'], 404);
932
        }
933
934
        // Stream file to the browser
935
        $resp = new BinaryFileResponse($abs);
936
        $resp->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file);
937
        // A sensible CC mime; many LMS aceptan zip también
938
        $resp->headers->set('Content-Type', 'application/vnd.ims.ccv1p3+imscc');
939
940
        return $resp;
941
    }
942
943
    #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])]
944
    public function cc13Import(int $node, Request $req): JsonResponse
945
    {
946
        $this->setDebugFromRequest($req);
947
948
        try {
949
            $file = $req->files->get('file');
950
            if (!$file || !$file->isValid()) {
951
                return $this->json(['error' => 'Missing or invalid upload.'], 400);
952
            }
953
954
            $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION));
955
            if (!in_array($ext, ['imscc', 'zip'], true)) {
956
                return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 415);
957
            }
958
959
            // Move to a temp file
960
            $tmpZip = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.
961
                'cc13_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)).'.'.$ext;
962
            $file->move(dirname($tmpZip), basename($tmpZip));
963
964
            // Extract
965
            $extractDir = Imscc13Import::unzip($tmpZip);
966
967
            // Detect and validate format
968
            $format = Imscc13Import::detectFormat($extractDir);
969
            if ($format !== Imscc13Import::FORMAT_IMSCC13) {
970
                Imscc13Import::rrmdir($extractDir);
971
                @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

971
                /** @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...
972
973
                return $this->json(['error' => 'This package is not a Common Cartridge 1.3.'], 400);
974
            }
975
976
            // Execute import (creates Chamilo resources)
977
            $importer = new Imscc13Import();
978
            $importer->execute($extractDir);
979
980
            // Cleanup
981
            Imscc13Import::rrmdir($extractDir);
982
            @unlink($tmpZip);
983
984
            return $this->json([
985
                'ok' => true,
986
                'message' => 'CC 1.3 import completed successfully.',
987
            ]);
988
        } catch (\Throwable $e) {
989
            return $this->json([
990
                'error' => 'CC 1.3 import failed: '.$e->getMessage(),
991
            ], 500);
992
        }
993
    }
994
995
    // --------------------------------------------------------------------------------
996
    // Helpers to build the Vue-ready resource tree
997
    // --------------------------------------------------------------------------------
998
999
    /**
1000
     * Copies the dependencies (document, link, quiz, etc.) to $course->resources
1001
     * that reference the selected LearnPaths, taking the items from the full snapshot.
1002
     *
1003
     * It doesn't break anything if something is missing or comes in a different format: it's defensive.
1004
     */
1005
    private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void
1006
    {
1007
        if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) {
1008
            return;
1009
        }
1010
1011
        $depTypes = [
1012
            'document', 'link', 'quiz', 'work', 'survey',
1013
            'Forum_Category', 'forum', 'thread', 'post',
1014
            'Exercise_Question', 'survey_question', 'Link_Category',
1015
        ];
1016
1017
        $need = [];
1018
        $addNeed = function (string $type, $id) use (&$need): void {
1019
            $t = (string) $type;
1020
            $i = is_numeric($id) ? (int) $id : (string) $id;
1021
            if ('' === $i || 0 === $i) {
1022
                return;
1023
            }
1024
            $need[$t] ??= [];
1025
            $need[$t][$i] = true;
1026
        };
1027
1028
        foreach ($course->resources['learnpath'] as $lpId => $lpWrap) {
1029
            $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap;
1030
1031
            if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) {
1032
                foreach ($lpWrap->linked_resources as $t => $ids) {
1033
                    if (!\is_array($ids)) {
1034
                        continue;
1035
                    }
1036
                    foreach ($ids as $rid) {
1037
                        $addNeed($t, $rid);
1038
                    }
1039
                }
1040
            }
1041
1042
            $items = [];
1043
            if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) {
1044
                $items = $lp->items;
1045
            } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) {
1046
                $items = $lpWrap->items;
1047
            }
1048
1049
            foreach ($items as $it) {
1050
                $ito = \is_object($it) ? $it : (object) $it;
1051
1052
                if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) {
1053
                    foreach ($ito->linked_resources as $t => $ids) {
1054
                        if (!\is_array($ids)) {
1055
                            continue;
1056
                        }
1057
                        foreach ($ids as $rid) {
1058
                            $addNeed($t, $rid);
1059
                        }
1060
                    }
1061
                }
1062
1063
                foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) {
1064
                    if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) {
1065
                        $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field};
1066
                        $t = $typeGuess ?: (string) ($ito->type ?? '');
1067
                        if ('' !== $t) {
1068
                            $addNeed($t, $rid);
1069
                        }
1070
                    }
1071
                }
1072
1073
                if (!empty($ito->type) && isset($ito->ref)) {
1074
                    $addNeed((string) $ito->type, $ito->ref);
1075
                }
1076
            }
1077
        }
1078
1079
        if (empty($need)) {
1080
            $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'];
1081
            foreach ($core as $k) {
1082
                if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) {
1083
                    $course->resources[$k] ??= [];
1084
                    if (0 === \count($course->resources[$k])) {
1085
                        $course->resources[$k] = $snapshot[$k];
1086
                    }
1087
                }
1088
            }
1089
            $this->logDebug('[LP-deps] fallback filled from snapshot', [
1090
                '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)),
1091
            ]);
1092
1093
            return;
1094
        }
1095
1096
        foreach ($need as $type => $idMap) {
1097
            if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) {
1098
                continue;
1099
            }
1100
1101
            $course->resources[$type] ??= [];
1102
1103
            foreach (array_keys($idMap) as $rid) {
1104
                $src = $snapshot[$type][$rid]
1105
                    ?? $snapshot[$type][(string) $rid]
1106
                    ?? null;
1107
1108
                if (!$src) {
1109
                    continue;
1110
                }
1111
1112
                if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) {
1113
                    $course->resources[$type][$rid] = $src;
1114
                }
1115
            }
1116
        }
1117
1118
        $this->logDebug('[LP-deps] hydrated', [
1119
            'types' => array_keys($need),
1120
            'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)),
1121
        ]);
1122
    }
1123
1124
    /**
1125
     * Build a Vue-friendly tree from legacy Course.
1126
     */
1127
    private function buildResourceTreeForVue(object $course): array
1128
    {
1129
        if ($this->debug) {
1130
            $this->logDebug('[buildResourceTreeForVue] start');
1131
        }
1132
1133
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
1134
            ? $course->resources
1135
            : [];
1136
1137
        $legacyTitles = [];
1138
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
1139
            /** @var array<string,string> $legacyTitles */
1140
            $legacyTitles = CourseSelectForm::getResourceTitleList();
1141
        }
1142
        $fallbackTitles = $this->getDefaultTypeTitles();
1143
        $skipTypes = $this->getSkipTypeKeys();
1144
1145
        $tree = [];
1146
1147
        // Forums block
1148
        $hasForumData =
1149
            (!empty($resources['forum']) || !empty($resources['Forum']))
1150
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1151
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1152
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1153
1154
        if ($hasForumData) {
1155
            $tree[] = $this->buildForumTreeForVue(
1156
                $course,
1157
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1158
            );
1159
            $skipTypes['forum'] = true;
1160
            $skipTypes['forum_category'] = true;
1161
            $skipTypes['forum_topic'] = true;
1162
            $skipTypes['forum_post'] = true;
1163
            $skipTypes['thread'] = true;
1164
            $skipTypes['post'] = true;
1165
        }
1166
1167
        // Links block (Category → Link)
1168
        $hasLinkData =
1169
            (!empty($resources['link']) || !empty($resources['Link']))
1170
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1171
1172
        if ($hasLinkData) {
1173
            $tree[] = $this->buildLinkTreeForVue(
1174
                $course,
1175
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1176
            );
1177
            // Prevent generic loop from adding separate "link" and "link_category" groups
1178
            $skipTypes['link'] = true;
1179
            $skipTypes['link_category'] = true;
1180
        }
1181
1182
        // Other tools
1183
        foreach ($resources as $rawType => $items) {
1184
            if (!\is_array($items) || empty($items)) {
1185
                continue;
1186
            }
1187
            $typeKey = $this->normalizeTypeKey($rawType);
1188
            if (isset($skipTypes[$typeKey])) {
1189
                continue;
1190
            }
1191
1192
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1193
            $group = [
1194
                'type' => $typeKey,
1195
                'title' => (string) $groupTitle,
1196
                'items' => [],
1197
            ];
1198
1199
            if ('gradebook' === $typeKey) {
1200
                $group['items'][] = [
1201
                    'id' => 'all',
1202
                    'label' => 'Gradebook (all)',
1203
                    'extra' => new stdClass(),
1204
                    'selectable' => true,
1205
                ];
1206
                $tree[] = $group;
1207
1208
                continue;
1209
            }
1210
1211
            foreach ($items as $id => $obj) {
1212
                if (!\is_object($obj)) {
1213
                    continue;
1214
                }
1215
1216
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1217
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1218
                    continue;
1219
                }
1220
1221
                if (!$this->isSelectableItem($typeKey, $obj)) {
1222
                    continue;
1223
                }
1224
1225
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1226
                if ('document' === $typeKey) {
1227
                    $e = $this->objectEntity($obj);
1228
                    $rawPath = (string) ($e->path ?? '');
1229
                    if ('' !== $rawPath) {
1230
                        $rel = ltrim($rawPath, '/');
1231
                        $rel = preg_replace('~^document/?~', '', $rel);
1232
                        $filetype = (string) ($e->filetype ?? $e->file_type ?? '');
1233
                        if ('folder' === $filetype) {
1234
                            $rel = rtrim($rel, '/').'/';
1235
                        }
1236
                        if ('' !== $rel) {
1237
                            $label = $rel;
1238
                        }
1239
                    }
1240
                }
1241
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1242
                    $label = $idKey;
1243
                }
1244
1245
                $extra = $this->buildExtra($typeKey, $obj);
1246
1247
                $group['items'][] = [
1248
                    'id' => $idKey,
1249
                    'label' => $label,
1250
                    'extra' => $extra ?: new stdClass(),
1251
                    'selectable' => true,
1252
                ];
1253
            }
1254
1255
            if (!empty($group['items'])) {
1256
                usort(
1257
                    $group['items'],
1258
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1259
                );
1260
                $tree[] = $group;
1261
            }
1262
        }
1263
1264
        // Preferred order
1265
        $preferredOrder = [
1266
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
1267
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook',
1268
        ];
1269
        usort($tree, static function ($a, $b) use ($preferredOrder) {
1270
            $ia = array_search($a['type'], $preferredOrder, true);
1271
            $ib = array_search($b['type'], $preferredOrder, true);
1272
            if (false !== $ia && false !== $ib) {
1273
                return $ia <=> $ib;
1274
            }
1275
            if (false !== $ia) {
1276
                return -1;
1277
            }
1278
            if (false !== $ib) {
1279
                return 1;
1280
            }
1281
1282
            return strcasecmp($a['title'], $b['title']);
1283
        });
1284
1285
        if ($this->debug) {
1286
            $this->logDebug(
1287
                '[buildResourceTreeForVue] end groups',
1288
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? [])], $tree)
1289
            );
1290
        }
1291
1292
        return $tree;
1293
    }
1294
1295
    /**
1296
     * Build forum tree (Category → Forum → Topic) for the UI.
1297
     * Uses only "items" (no "children") and sets UI hints (has_children, item_count).
1298
     */
1299
    private function buildForumTreeForVue(object $course, string $groupTitle): array
1300
    {
1301
        $this->logDebug('[buildForumTreeForVue] start');
1302
1303
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
1304
1305
        // Buckets (defensive: accept legacy casings / aliases)
1306
        $catRaw   = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
1307
        $forumRaw = $res['forum']          ?? $res['Forum']          ?? [];
1308
        $topicRaw = $res['forum_topic']    ?? $res['ForumTopic']     ?? ($res['thread'] ?? []);
1309
        $postRaw  = $res['forum_post']     ?? $res['Forum_Post']     ?? ($res['post'] ?? []);
1310
1311
        $this->logDebug('[buildForumTreeForVue] raw counts', [
1312
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
1313
            'forums'     => \is_array($forumRaw) ? \count($forumRaw) : 0,
1314
            'topics'     => \is_array($topicRaw) ? \count($topicRaw) : 0,
1315
            'posts'      => \is_array($postRaw) ? \count($postRaw) : 0,
1316
        ]);
1317
1318
        // Quick classifiers (defensive)
1319
        $isForum = function (object $o): bool {
1320
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1321
            if (isset($e->forum_title) && \is_string($e->forum_title)) { return true; }
1322
            if (isset($e->default_view) || isset($e->allow_anonymous)) { return true; }
1323
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) { return true; }
1324
            return false;
1325
        };
1326
        $isTopic = function (object $o): bool {
1327
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1328
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) { return true; }
1329
            if (isset($e->forum_id) && !isset($e->forum_title)) { return true; }
1330
            return false;
1331
        };
1332
        $getForumCategoryId = function (object $forum): int {
1333
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
1334
            $cid = (int) ($e->forum_category ?? 0);
1335
            if ($cid <= 0) { $cid = (int) ($e->forum_category_id ?? 0); }
1336
            if ($cid <= 0) { $cid = (int) ($e->category_id ?? 0); }
1337
            return $cid;
1338
        };
1339
1340
        // Build categories
1341
        $cats = [];
1342
        foreach ($catRaw as $id => $obj) {
1343
            $id = (int) $id;
1344
            if ($id <= 0 || !\is_object($obj)) { continue; }
1345
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
1346
            $cats[$id] = [
1347
                'id'         => $id,
1348
                'type'       => 'forum_category',
1349
                'label'      => $label,
1350
                'selectable' => false,
1351
                'items'      => [],
1352
                // UI hints
1353
                'has_children' => false,
1354
                'item_count'   => 0,
1355
            ];
1356
        }
1357
        // Virtual "Uncategorized"
1358
        $uncatKey = -9999;
1359
        if (!isset($cats[$uncatKey])) {
1360
            $cats[$uncatKey] = [
1361
                'id'         => $uncatKey,
1362
                'type'       => 'forum_category',
1363
                'label'      => 'Uncategorized',
1364
                'selectable' => false,
1365
                'items'      => [],
1366
                '_virtual'   => true,
1367
                'has_children' => false,
1368
                'item_count'   => 0,
1369
            ];
1370
        }
1371
1372
        // Forums
1373
        $forums = [];
1374
        foreach ($forumRaw as $id => $obj) {
1375
            $id = (int) $id;
1376
            if ($id <= 0 || !\is_object($obj)) { continue; }
1377
            if (!$isForum($obj)) {
1378
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]);
1379
                continue;
1380
            }
1381
            $forums[$id] = $this->objectEntity($obj);
1382
        }
1383
1384
        // Topics (+ post counts)
1385
        $topics = [];
1386
        $postCountByTopic = [];
1387
        foreach ($topicRaw as $id => $obj) {
1388
            $id = (int) $id;
1389
            if ($id <= 0 || !\is_object($obj)) { continue; }
1390
            if ($isForum($obj) && !$isTopic($obj)) {
1391
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]);
1392
                continue;
1393
            }
1394
            if (!$isTopic($obj)) { continue; }
1395
            $topics[$id] = $this->objectEntity($obj);
1396
        }
1397
        foreach ($postRaw as $id => $obj) {
1398
            $id = (int) $id;
1399
            if ($id <= 0 || !\is_object($obj)) { continue; }
1400
            $e = $this->objectEntity($obj);
1401
            $tid = (int) ($e->thread_id ?? 0);
1402
            if ($tid > 0) { $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1; }
1403
        }
1404
1405
        // Attach topics to forums and forums to categories
1406
        foreach ($forums as $fid => $f) {
1407
            $catId = $getForumCategoryId($f);
1408
            if (!isset($cats[$catId])) { $catId = $uncatKey; }
1409
1410
            $forumNode = [
1411
                'id'         => $fid,
1412
                'type'       => 'forum',
1413
                'label'      => $this->resolveItemLabel('forum', $f, $fid),
1414
                'extra'      => $this->buildExtra('forum', $f) ?: new \stdClass(),
1415
                'selectable' => true,
1416
                'items'      => [],
1417
                // UI hints
1418
                'has_children' => false,
1419
                'item_count'   => 0,
1420
                'ui_depth'     => 2,
1421
            ];
1422
1423
            foreach ($topics as $tid => $t) {
1424
                if ((int) ($t->forum_id ?? 0) !== $fid) { continue; }
1425
1426
                $author  = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
1427
                $date    = (string) ($t->thread_date ?? '');
1428
                $nPosts  = (int) ($postCountByTopic[$tid] ?? 0);
1429
1430
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
1431
                $meta = [];
1432
                if ($author !== '') { $meta[] = $author; }
1433
                if ($date   !== '') { $meta[] = $date; }
1434
                if ($meta) { $topicLabel .= ' ('.implode(', ', $meta).')'; }
1435
                if ($nPosts > 0) { $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's'); }
1436
1437
                $forumNode['items'][] = [
1438
                    'id'         => $tid,
1439
                    'type'       => 'forum_topic',
1440
                    'label'      => $topicLabel,
1441
                    'extra'      => new \stdClass(),
1442
                    'selectable' => true,
1443
                    'ui_depth'   => 3,
1444
                    'item_count' => 0,
1445
                ];
1446
            }
1447
1448
            if (!empty($forumNode['items'])) {
1449
                usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1450
                $forumNode['has_children'] = true;
1451
                $forumNode['item_count']   = \count($forumNode['items']);
1452
            }
1453
1454
            $cats[$catId]['items'][] = $forumNode;
1455
        }
1456
1457
        // Remove empty virtual category; sort forums inside each category
1458
        $catNodes = array_values(array_filter($cats, static function ($c) {
1459
            if (!empty($c['_virtual']) && empty($c['items'])) { return false; }
1460
            return true;
1461
        }));
1462
1463
        // Flatten stray forums (defensive) and finalize UI hints
1464
        foreach ($catNodes as &$cat) {
1465
            if (!empty($cat['items'])) {
1466
                $lift = [];
1467
                foreach ($cat['items'] as &$forumNode) {
1468
                    if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) { continue; }
1469
                    $keep = [];
1470
                    foreach ($forumNode['items'] as $child) {
1471
                        if (($child['type'] ?? '') === 'forum') {
1472
                            $lift[] = $child;
1473
                            $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [
1474
                                'parent_forum_id' => $forumNode['id'] ?? null,
1475
                                'lifted_forum_id' => $child['id'] ?? null,
1476
                                'cat_id'          => $cat['id'] ?? null,
1477
                            ]);
1478
                        } else {
1479
                            $keep[] = $child;
1480
                        }
1481
                    }
1482
                    $forumNode['items']        = $keep;
1483
                    $forumNode['has_children'] = !empty($keep);
1484
                    $forumNode['item_count']   = \count($keep);
1485
                }
1486
                unset($forumNode);
1487
1488
                foreach ($lift as $n) { $cat['items'][] = $n; }
1489
                usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1490
            }
1491
1492
            // UI hints for category
1493
            $cat['has_children'] = !empty($cat['items']);
1494
            $cat['item_count']   = \count($cat['items'] ?? []);
1495
        }
1496
        unset($cat);
1497
1498
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
1499
1500
        return [
1501
            'type'  => 'forum',
1502
            'title' => $groupTitle,
1503
            'items' => $catNodes,
1504
        ];
1505
    }
1506
1507
    /**
1508
     * Normalize a raw type to a lowercase key.
1509
     */
1510
    private function normalizeTypeKey(int|string $raw): string
1511
    {
1512
        if (\is_int($raw)) {
1513
            return (string) $raw;
1514
        }
1515
1516
        $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw));
1517
1518
        $map = [
1519
            'forum_category' => 'forum_category',
1520
            'forumtopic' => 'forum_topic',
1521
            'forum_topic' => 'forum_topic',
1522
            'forum_post' => 'forum_post',
1523
            'thread' => 'forum_topic',
1524
            'post' => 'forum_post',
1525
            'exercise_question' => 'exercise_question',
1526
            'surveyquestion' => 'survey_question',
1527
            'surveyinvitation' => 'survey_invitation',
1528
            'survey' => 'survey',
1529
            'link_category' => 'link_category',
1530
            'coursecopylearnpath' => 'learnpath',
1531
            'coursecopytestcategory' => 'test_category',
1532
            'coursedescription' => 'course_description',
1533
            'session_course' => 'session_course',
1534
            'gradebookbackup' => 'gradebook',
1535
            'scormdocument' => 'scorm',
1536
            'tool/introduction' => 'tool_intro',
1537
            'tool_introduction' => 'tool_intro',
1538
        ];
1539
1540
        return $map[$s] ?? $s;
1541
    }
1542
1543
    /**
1544
     * Keys to skip as top-level groups in UI.
1545
     *
1546
     * @return array<string,bool>
1547
     */
1548
    private function getSkipTypeKeys(): array
1549
    {
1550
        return [
1551
            'forum_category' => true,
1552
            'forum_topic' => true,
1553
            'forum_post' => true,
1554
            'thread' => true,
1555
            'post' => true,
1556
            'exercise_question' => true,
1557
            'survey_question' => true,
1558
            'survey_invitation' => true,
1559
            'session_course' => true,
1560
            'scorm' => true,
1561
            'asset' => true,
1562
            'link_category' => true,
1563
        ];
1564
    }
1565
1566
    /**
1567
     * Default labels for groups.
1568
     *
1569
     * @return array<string,string>
1570
     */
1571
    private function getDefaultTypeTitles(): array
1572
    {
1573
        return [
1574
            'announcement' => 'Announcements',
1575
            'document' => 'Documents',
1576
            'glossary' => 'Glossaries',
1577
            'calendar_event' => 'Calendar events',
1578
            'event' => 'Calendar events',
1579
            'link' => 'Links',
1580
            'course_description' => 'Course descriptions',
1581
            'learnpath' => 'Parcours',
1582
            'learnpath_category' => 'Learning path categories',
1583
            'forum' => 'Forums',
1584
            'forum_category' => 'Forum categories',
1585
            'quiz' => 'Exercices',
1586
            'test_category' => 'Test categories',
1587
            'wiki' => 'Wikis',
1588
            'thematic' => 'Thematics',
1589
            'attendance' => 'Attendances',
1590
            'work' => 'Works',
1591
            'session_course' => 'Session courses',
1592
            'gradebook' => 'Gradebook',
1593
            'scorm' => 'SCORM packages',
1594
            'survey' => 'Surveys',
1595
            'survey_question' => 'Survey questions',
1596
            'survey_invitation' => 'Survey invitations',
1597
            'asset' => 'Assets',
1598
            'tool_intro' => 'Tool introductions',
1599
        ];
1600
    }
1601
1602
    /**
1603
     * Decide if an item is selectable (UI).
1604
     */
1605
    private function isSelectableItem(string $type, object $obj): bool
1606
    {
1607
        if ('document' === $type) {
1608
            return true;
1609
        }
1610
1611
        return true;
1612
    }
1613
1614
    /**
1615
     * Resolve label for an item with fallbacks.
1616
     */
1617
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
1618
    {
1619
        $entity = $this->objectEntity($obj);
1620
1621
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
1622
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
1623
                return trim((string) $entity->{$k});
1624
            }
1625
        }
1626
1627
        if (isset($obj->params) && \is_array($obj->params)) {
1628
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
1629
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
1630
                    return (string) $obj->params[$k];
1631
                }
1632
            }
1633
        }
1634
1635
        switch ($type) {
1636
            case 'document':
1637
                // 1) ruta cruda tal como viene del backup/DB
1638
                $raw = (string) ($entity->path ?? $obj->path ?? '');
1639
                if ('' !== $raw) {
1640
                    // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup
1641
                    $rel = ltrim($raw, '/');
1642
                    $rel = preg_replace('~^document/?~', '', $rel);
1643
1644
                    // 3) carpeta ⇒ que termine con "/"
1645
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
1646
                    if ('folder' === $fileType) {
1647
                        $rel = rtrim($rel, '/').'/';
1648
                    }
1649
1650
                    // 4) si la ruta quedó vacía, usa basename como último recurso
1651
                    return '' !== $rel ? $rel : basename($raw);
1652
                }
1653
1654
                // fallback: título o nombre de archivo
1655
                if (!empty($obj->title)) {
1656
                    return (string) $obj->title;
1657
                }
1658
1659
                break;
1660
1661
            case 'course_description':
1662
                if (!empty($obj->title)) {
1663
                    return (string) $obj->title;
1664
                }
1665
                $t = (int) ($obj->description_type ?? 0);
1666
                $names = [
1667
                    1 => 'Description',
1668
                    2 => 'Objectives',
1669
                    3 => 'Topics',
1670
                    4 => 'Methodology',
1671
                    5 => 'Course material',
1672
                    6 => 'Resources',
1673
                    7 => 'Assessment',
1674
                    8 => 'Custom',
1675
                ];
1676
1677
                return $names[$t] ?? ('#'.$fallbackId);
1678
1679
            case 'announcement':
1680
                if (!empty($obj->title)) {
1681
                    return (string) $obj->title;
1682
                }
1683
1684
                break;
1685
1686
            case 'forum':
1687
                if (!empty($entity->forum_title)) {
1688
                    return (string) $entity->forum_title;
1689
                }
1690
1691
                break;
1692
1693
            case 'forum_category':
1694
                if (!empty($entity->cat_title)) {
1695
                    return (string) $entity->cat_title;
1696
                }
1697
1698
                break;
1699
1700
            case 'link':
1701
                if (!empty($obj->title)) {
1702
                    return (string) $obj->title;
1703
                }
1704
                if (!empty($obj->url)) {
1705
                    return (string) $obj->url;
1706
                }
1707
1708
                break;
1709
1710
            case 'survey':
1711
                if (!empty($obj->title)) {
1712
                    return trim((string) $obj->title);
1713
                }
1714
1715
                break;
1716
1717
            case 'learnpath':
1718
                if (!empty($obj->name)) {
1719
                    return (string) $obj->name;
1720
                }
1721
1722
                break;
1723
1724
            case 'thematic':
1725
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
1726
                    return (string) $obj->params['title'];
1727
                }
1728
1729
                break;
1730
1731
            case 'quiz':
1732
                if (!empty($entity->title)) {
1733
                    return (string) $entity->title;
1734
                }
1735
1736
                break;
1737
1738
            case 'forum_topic':
1739
                if (!empty($entity->thread_title)) {
1740
                    return (string) $entity->thread_title;
1741
                }
1742
1743
                break;
1744
        }
1745
1746
        return '#'.$fallbackId;
1747
    }
1748
1749
    /**
1750
     * Extract wrapped entity (->obj) or the object itself.
1751
     */
1752
    private function objectEntity(object $resource): object
1753
    {
1754
        if (isset($resource->obj) && \is_object($resource->obj)) {
1755
            return $resource->obj;
1756
        }
1757
1758
        return $resource;
1759
    }
1760
1761
    /**
1762
     * Extra payload per item for UI (optional).
1763
     */
1764
    private function buildExtra(string $type, object $obj): array
1765
    {
1766
        $extra = [];
1767
1768
        $get = static function (object $o, string $k, $default = null) {
1769
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
1770
        };
1771
1772
        switch ($type) {
1773
            case 'document':
1774
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
1775
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
1776
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
1777
1778
                break;
1779
1780
            case 'link':
1781
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
1782
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
1783
1784
                break;
1785
1786
            case 'forum':
1787
                $entity = $this->objectEntity($obj);
1788
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
1789
                $extra['default_view'] = (string) ($entity->default_view ?? '');
1790
1791
                break;
1792
1793
            case 'learnpath':
1794
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
1795
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
1796
                    return [
1797
                        'id' => (int) ($i['id'] ?? 0),
1798
                        'title' => (string) ($i['title'] ?? ''),
1799
                        'type' => (string) ($i['item_type'] ?? ''),
1800
                        'path' => (string) ($i['path'] ?? ''),
1801
                    ];
1802
                }, $obj->items) : [];
1803
1804
                break;
1805
1806
            case 'thematic':
1807
                if (isset($obj->params) && \is_array($obj->params)) {
1808
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
1809
                }
1810
1811
                break;
1812
1813
            case 'quiz':
1814
                $entity = $this->objectEntity($obj);
1815
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
1816
                    ? array_map('intval', $entity->question_ids)
1817
                    : [];
1818
1819
                break;
1820
1821
            case 'survey':
1822
                $entity = $this->objectEntity($obj);
1823
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
1824
                    ? array_map('intval', $entity->question_ids)
1825
                    : [];
1826
1827
                break;
1828
        }
1829
1830
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
1831
    }
1832
1833
    // --------------------------------------------------------------------------------
1834
    // Selection filtering (used by partial restore)
1835
    // --------------------------------------------------------------------------------
1836
1837
    /**
1838
     * Get first existing key from candidates.
1839
     */
1840
    private function firstExistingKey(array $orig, array $candidates): ?string
1841
    {
1842
        foreach ($candidates as $k) {
1843
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
1844
                return $k;
1845
            }
1846
        }
1847
1848
        return null;
1849
    }
1850
1851
    /**
1852
     * Filter legacy Course by UI selections (and pull dependencies).
1853
     *
1854
     * @param array $selected [type => [id => true]]
1855
     */
1856
    private function filterLegacyCourseBySelection(object $course, array $selected): object
1857
    {
1858
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
1859
1860
        if (empty($course->resources) || !\is_array($course->resources)) {
1861
            $this->logDebug('[filterSelection] course has no resources');
1862
1863
            return $course;
1864
        }
1865
1866
        /** @var array<string,mixed> $orig */
1867
        $orig = $course->resources;
1868
1869
        // Keep meta buckets (keys starting with "__") so we don't lose import_source, etc.
1870
        $__metaBuckets = [];
1871
        foreach ($orig as $k => $v) {
1872
            if (\is_string($k) && str_starts_with($k, '__')) {
1873
                $__metaBuckets[$k] = $v;
1874
            }
1875
        }
1876
1877
        $getBucket = static function (array $a, string $key): array {
1878
            return (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
1879
        };
1880
1881
        // Forums flow
1882
        if (!empty($selected) && !empty($selected['forum'])) {
1883
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
1884
            if (!empty($selForums)) {
1885
                $forums = $getBucket($orig, 'forum');
1886
                $catsToKeep = [];
1887
1888
                foreach ($forums as $fid => $f) {
1889
                    if (!isset($selForums[(string) $fid])) {
1890
                        continue;
1891
                    }
1892
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
1893
                    $cid = (int) ($e->forum_category ?? 0);
1894
                    if ($cid > 0) {
1895
                        $catsToKeep[$cid] = true;
1896
                    }
1897
                }
1898
1899
                $threads = $getBucket($orig, 'thread');
1900
                $threadToKeep = [];
1901
                foreach ($threads as $tid => $t) {
1902
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
1903
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
1904
                        $threadToKeep[(int) $tid] = true;
1905
                    }
1906
                }
1907
1908
                $posts = $getBucket($orig, 'post');
1909
                $postToKeep = [];
1910
                foreach ($posts as $pid => $p) {
1911
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
1912
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
1913
                        $postToKeep[(int) $pid] = true;
1914
                    }
1915
                }
1916
1917
                $out = [];
1918
                foreach ($selected as $type => $ids) {
1919
                    if (!\is_array($ids) || empty($ids)) {
1920
                        continue;
1921
                    }
1922
                    $bucket = $getBucket($orig, (string) $type);
1923
                    if (!empty($bucket)) {
1924
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
1925
                        $out[$type] = array_intersect_key($bucket, $idsMap);
1926
                    }
1927
                }
1928
1929
                $forumCat = $getBucket($orig, 'Forum_Category');
1930
                if (!empty($forumCat)) {
1931
                    $out['Forum_Category'] = array_intersect_key(
1932
                        $forumCat,
1933
                        array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
1934
                    );
1935
                }
1936
1937
                $forumBucket = $getBucket($orig, 'forum');
1938
                if (!empty($forumBucket)) {
1939
                    $out['forum'] = array_intersect_key($forumBucket, $selForums);
1940
                }
1941
1942
                $threadBucket = $getBucket($orig, 'thread');
1943
                if (!empty($threadBucket)) {
1944
                    $out['thread'] = array_intersect_key(
1945
                        $threadBucket,
1946
                        array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
1947
                    );
1948
                }
1949
1950
                $postBucket = $getBucket($orig, 'post');
1951
                if (!empty($postBucket)) {
1952
                    $out['post'] = array_intersect_key(
1953
                        $postBucket,
1954
                        array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
1955
                    );
1956
                }
1957
1958
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
1959
                    $out['Forum_Category'] = $forumCat;
1960
                }
1961
1962
                // Preserve meta buckets
1963
                if (!empty($__metaBuckets)) {
1964
                    $out = array_filter($out);
1965
                    $course->resources = $__metaBuckets + $out;
1966
                } else {
1967
                    $course->resources = array_filter($out);
1968
                }
1969
1970
                $this->logDebug('[filterSelection] end', [
1971
                    'kept_types' => array_keys($course->resources),
1972
                    'forum_counts' => [
1973
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
1974
                        'forum' => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
1975
                        'thread' => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
1976
                        'post' => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
1977
                    ],
1978
                ]);
1979
1980
                return $course;
1981
            }
1982
        }
1983
1984
        // Generic + quiz/survey/gradebook flows
1985
        $alias = [
1986
            'tool_intro' => 'Tool introduction',
1987
        ];
1988
1989
        $keep = [];
1990
        foreach ($selected as $type => $ids) {
1991
            if (!\is_array($ids) || empty($ids)) {
1992
                continue;
1993
            }
1994
1995
            $legacyKey = $type;
1996
            if (!isset($orig[$legacyKey]) && isset($alias[$type])) {
1997
                $legacyKey = $alias[$type];
1998
            }
1999
2000
            $bucket = $getBucket($orig, (string) $legacyKey);
2001
            if (!empty($bucket)) {
2002
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2003
                $keep[$legacyKey] = array_intersect_key($bucket, $idsMap);
2004
            }
2005
        }
2006
2007
        // Gradebook bucket
2008
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
2009
        if ($gbKey && !empty($selected['gradebook'])) {
2010
            $gbBucket = $getBucket($orig, $gbKey);
2011
            if (!empty($gbBucket)) {
2012
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
2013
                $firstItem = reset($gbBucket);
2014
2015
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
2016
                    $keep[$gbKey] = $gbBucket;
2017
                    $this->logDebug('[filterSelection] kept full gradebook bucket', ['key' => $gbKey, 'count' => \count($gbBucket)]);
2018
                } else {
2019
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
2020
                    $this->logDebug('[filterSelection] kept partial gradebook bucket', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
2021
                }
2022
            }
2023
        }
2024
2025
        // Quizzes → questions (+ images)
2026
        $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']);
2027
        if ($quizKey && !empty($keep[$quizKey])) {
2028
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
2029
            if ($questionKey) {
2030
                $qids = [];
2031
                foreach ($keep[$quizKey] as $qid => $qwrap) {
2032
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2033
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
2034
                        foreach ($q->question_ids as $sid) {
2035
                            $qids[(string) $sid] = true;
2036
                        }
2037
                    }
2038
                }
2039
2040
                if (!empty($qids)) {
2041
                    $questionBucket = $getBucket($orig, $questionKey);
2042
                    $selQ = array_intersect_key($questionBucket, $qids);
2043
                    if (!empty($selQ)) {
2044
                        $keep[$questionKey] = $selQ;
2045
                        $this->logDebug('[filterSelection] pulled question bucket for quizzes', [
2046
                            'quiz_count' => \count($keep[$quizKey]),
2047
                            'question_key' => $questionKey,
2048
                            'questions_kept' => \count($keep[$questionKey]),
2049
                        ]);
2050
2051
                        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2052
                        if ($docKey) {
2053
                            $docBucket = $getBucket($orig, $docKey);
2054
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
2055
                            if (!empty($imageQuizBucket)) {
2056
                                $needed = [];
2057
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
2058
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2059
                                    $pic = (string) ($q->picture ?? '');
2060
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
2061
                                        $needed[$pic] = true;
2062
                                    }
2063
                                }
2064
                                if (!empty($needed)) {
2065
                                    $keep[$docKey] = $keep[$docKey] ?? [];
2066
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
2067
                                    $this->logDebug('[filterSelection] included image_quiz docs for questions', [
2068
                                        'count' => \count($keep[$docKey]['image_quiz']),
2069
                                    ]);
2070
                                }
2071
                            }
2072
                        }
2073
                    }
2074
                }
2075
            } else {
2076
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found in backup');
2077
            }
2078
        }
2079
2080
        // Surveys → questions (+ invitations)
2081
        $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']);
2082
        if ($surveyKey && !empty($keep[$surveyKey])) {
2083
            $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
2084
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
2085
2086
            if ($surveyQuestionKey) {
2087
                $neededQids = [];
2088
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
2089
2090
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
2091
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
2092
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
2093
                        foreach ($s->question_ids as $qid) {
2094
                            $neededQids[(string) $qid] = true;
2095
                        }
2096
                    }
2097
                }
2098
2099
                if (empty($neededQids)) {
2100
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2101
                    foreach ($surveyQBucket as $qid => $qWrap) {
2102
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2103
                        $qSurveyId = (string) ($q->survey_id ?? '');
2104
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2105
                            $neededQids[(string) $qid] = true;
2106
                        }
2107
                    }
2108
                }
2109
2110
                if (!empty($neededQids)) {
2111
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2112
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2113
                    $this->logDebug('[filterSelection] pulled question bucket for surveys', [
2114
                        'survey_count' => \count($keep[$surveyKey]),
2115
                        'question_key' => $surveyQuestionKey,
2116
                        'questions_kept' => \count($keep[$surveyQuestionKey]),
2117
                    ]);
2118
                } else {
2119
                    $this->logDebug('[filterSelection] surveys selected but no matching questions found');
2120
                }
2121
            } else {
2122
                $this->logDebug('[filterSelection] surveys selected but no question bucket found in backup');
2123
            }
2124
2125
            if ($surveyInvitationKey) {
2126
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2127
                if (!empty($invBucket)) {
2128
                    $neededInv = [];
2129
                    foreach ($invBucket as $iid => $invWrap) {
2130
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2131
                        $sid = (string) ($inv->survey_id ?? '');
2132
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2133
                            $neededInv[(string) $iid] = true;
2134
                        }
2135
                    }
2136
                    if (!empty($neededInv)) {
2137
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2138
                        $this->logDebug('[filterSelection] included survey invitations', [
2139
                            'invitations_kept' => \count($keep[$surveyInvitationKey]),
2140
                        ]);
2141
                    }
2142
                }
2143
            }
2144
        }
2145
2146
        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2147
        if ($docKey && !empty($keep[$docKey])) {
2148
            $docBucket = $getBucket($orig, $docKey);
2149
2150
            $foldersByRel = [];
2151
            foreach ($docBucket as $fid => $res) {
2152
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2153
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2154
                $isFolder = ('folder' === $ftRaw);
2155
                if (!$isFolder) {
2156
                    $pTest = (string) ($e->path ?? '');
2157
                    if ('' !== $pTest) {
2158
                        $isFolder = ('/' === substr($pTest, -1));
2159
                    }
2160
                }
2161
                if (!$isFolder) {
2162
                    continue;
2163
                }
2164
2165
                $p = (string) ($e->path ?? '');
2166
                if ('' === $p) {
2167
                    continue;
2168
                }
2169
2170
                $frel = '/'.ltrim(substr($p, 8), '/');
2171
                $frel = rtrim($frel, '/').'/';
2172
                if ('//' !== $frel) {
2173
                    $foldersByRel[$frel] = $fid;
2174
                }
2175
            }
2176
2177
            $needFolderIds = [];
2178
            foreach ($keep[$docKey] as $id => $res) {
2179
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2180
2181
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2182
                $isFolder = ('folder' === $ftRaw) || ('/' === substr((string) ($e->path ?? ''), -1));
2183
                if ($isFolder) {
2184
                    continue;
2185
                }
2186
2187
                $p = (string) ($e->path ?? '');
2188
                if ('' === $p) {
2189
                    continue;
2190
                }
2191
2192
                $rel = '/'.ltrim(substr($p, 8), '/');
2193
                $dir = rtrim(\dirname($rel), '/');
2194
                if ('' === $dir) {
2195
                    continue;
2196
                }
2197
2198
                $acc = '';
2199
                foreach (array_filter(explode('/', $dir)) as $seg) {
2200
                    $acc .= '/'.$seg;
2201
                    $accKey = rtrim($acc, '/').'/';
2202
                    if (isset($foldersByRel[$accKey])) {
2203
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2204
                    }
2205
                }
2206
            }
2207
2208
            if (!empty($needFolderIds)) {
2209
                $added = array_intersect_key($docBucket, $needFolderIds);
2210
                $keep[$docKey] += $added;
2211
2212
                $this->logDebug('[filterSelection] added parent folders for selected documents', [
2213
                    'doc_key' => $docKey,
2214
                    'added_folders' => \count($added),
2215
                ]);
2216
            }
2217
        }
2218
2219
        $lnkKey = $this->firstExistingKey(
2220
            $orig,
2221
            ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']
2222
        );
2223
2224
        if ($lnkKey && !empty($keep[$lnkKey])) {
2225
            $catIdsUsed = [];
2226
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2227
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2228
                $cid = (int) ($L->category_id ?? 0);
2229
                if ($cid > 0) {
2230
                    $catIdsUsed[(string) $cid] = true;
2231
                }
2232
            }
2233
2234
            $catKey = $this->firstExistingKey(
2235
                $orig,
2236
                ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']
2237
            );
2238
2239
            if ($catKey && !empty($catIdsUsed)) {
2240
                $catBucket = $getBucket($orig, $catKey);
2241
                if (!empty($catBucket)) {
2242
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
2243
                    $keep[$catKey] = $subset;
2244
                    $keep['link_category'] = $subset;
2245
2246
                    $this->logDebug('[filterSelection] pulled link categories for selected links', [
2247
                        'link_key' => $lnkKey,
2248
                        'category_key' => $catKey,
2249
                        'links_kept' => \count($keep[$lnkKey]),
2250
                        'cats_kept' => \count($subset),
2251
                        'mirrored_to' => 'link_category',
2252
                    ]);
2253
                }
2254
            } else {
2255
                $this->logDebug('[filterSelection] link category bucket not found in backup');
2256
            }
2257
        }
2258
2259
        $keep = array_filter($keep);
2260
        if (!empty($__metaBuckets)) {
2261
            $course->resources = $__metaBuckets + $keep;
2262
        } else {
2263
            $course->resources = $keep;
2264
        }
2265
2266
        $this->logDebug('[filterSelection] non-forum flow end', [
2267
            'kept_types' => array_keys($course->resources),
2268
        ]);
2269
2270
        return $course;
2271
    }
2272
2273
    /**
2274
     * Map UI options (1/2/3) to legacy file policy.
2275
     */
2276
    private function mapSameNameOption(int $opt): int
2277
    {
2278
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
2279
2280
        if (!\defined('FILE_SKIP')) {
2281
            \define('FILE_SKIP', 1);
2282
        }
2283
        if (!\defined('FILE_RENAME')) {
2284
            \define('FILE_RENAME', 2);
2285
        }
2286
        if (!\defined('FILE_OVERWRITE')) {
2287
            \define('FILE_OVERWRITE', 3);
2288
        }
2289
2290
        return match ($opt) {
2291
            1 => FILE_SKIP,
2292
            3 => FILE_OVERWRITE,
2293
            default => FILE_RENAME,
2294
        };
2295
    }
2296
2297
    /**
2298
     * Set debug mode from Request (query/header).
2299
     */
2300
    private function setDebugFromRequest(?Request $req): void
2301
    {
2302
        if (!$req) {
2303
            return;
2304
        }
2305
        // Query param wins
2306
        if ($req->query->has('debug')) {
2307
            $this->debug = $req->query->getBoolean('debug');
2308
2309
            return;
2310
        }
2311
        // Fallback to header
2312
        $hdr = $req->headers->get('X-Debug');
2313
        if (null !== $hdr) {
2314
            $val = trim((string) $hdr);
2315
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
2316
        }
2317
    }
2318
2319
    /**
2320
     * Debug logger with stage + compact JSON payload.
2321
     */
2322
    private function logDebug(string $stage, mixed $payload = null): void
2323
    {
2324
        if (!$this->debug) {
2325
            return;
2326
        }
2327
        $prefix = 'COURSE_DEBUG';
2328
        if (null === $payload) {
2329
            error_log("$prefix: $stage");
2330
2331
            return;
2332
        }
2333
        // Safe/short json
2334
        $json = null;
2335
2336
        try {
2337
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
2338
            if (null !== $json && \strlen($json) > 8000) {
2339
                $json = substr($json, 0, 8000).'…(truncated)';
2340
            }
2341
        } catch (Throwable $e) {
2342
            $json = '[payload_json_error: '.$e->getMessage().']';
2343
        }
2344
        error_log("$prefix: $stage -> $json");
2345
    }
2346
2347
    /**
2348
     * Snapshot of resources bag for quick inspection.
2349
     */
2350
    private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array
2351
    {
2352
        $out = [];
2353
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2354
        $i = 0;
2355
        foreach ($res as $type => $bag) {
2356
            if ($i++ >= $maxTypes) {
2357
                $out['__notice'] = 'types truncated';
2358
2359
                break;
2360
            }
2361
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
2362
            if (\is_array($bag)) {
2363
                $j = 0;
2364
                foreach ($bag as $id => $obj) {
2365
                    if ($j++ >= $maxItemsPerType) {
2366
                        $snap['sample'][] = ['__notice' => 'truncated'];
2367
2368
                        break;
2369
                    }
2370
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
2371
                    $snap['sample'][] = [
2372
                        'id' => (string) $id,
2373
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
2374
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
2375
                    ];
2376
                }
2377
            }
2378
            $out[(string) $type] = $snap;
2379
        }
2380
2381
        return $out;
2382
    }
2383
2384
    /**
2385
     * Snapshot of forum-family counters.
2386
     */
2387
    private function snapshotForumCounts(object $course): array
2388
    {
2389
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
2390
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
2391
2392
        return [
2393
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
2394
            'forum' => $get('forum', 'Forum'),
2395
            'thread' => $get('thread', 'forum_topic'),
2396
            'post' => $get('post', 'forum_post'),
2397
        ];
2398
    }
2399
2400
    /**
2401
     * Builds the selection map [type => [id => true]] from high-level types.
2402
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
2403
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
2404
     *
2405
     * @param object   $course        Legacy Course with already hydrated resources
2406
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
2407
     *
2408
     * @return array<string, array<int|string, bool>>
2409
     */
2410
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
2411
    {
2412
        $selectedTypes = array_map(
2413
            fn ($t) => $this->normalizeTypeKey((string) $t),
2414
            $selectedTypes
2415
        );
2416
2417
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2418
2419
        $coreDeps = [
2420
            'document', 'link', 'quiz', 'work', 'survey',
2421
            'Forum_Category', 'forum', 'thread', 'post',
2422
            'exercise_question', 'survey_question', 'link_category',
2423
        ];
2424
2425
        $presentKeys = array_fill_keys(array_map(
2426
            fn ($k) => $this->normalizeTypeKey((string) $k),
2427
            array_keys($res)
2428
        ), true);
2429
2430
        $out = [];
2431
2432
        $addBucket = function (string $typeKey) use (&$out, $res): void {
2433
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
2434
                return;
2435
            }
2436
            $ids = [];
2437
            foreach ($res[$typeKey] as $id => $_) {
2438
                $ids[(string) $id] = true;
2439
            }
2440
            if ($ids) {
2441
                $out[$typeKey] = $ids;
2442
            }
2443
        };
2444
2445
        foreach ($selectedTypes as $t) {
2446
            $addBucket($t);
2447
2448
            if ('learnpath' === $t) {
2449
                foreach ($coreDeps as $depRaw) {
2450
                    $dep = $this->normalizeTypeKey($depRaw);
2451
                    if (isset($presentKeys[$dep])) {
2452
                        $addBucket($dep);
2453
                    }
2454
                }
2455
            }
2456
        }
2457
2458
        $this->logDebug('[buildSelectionFromTypes] built', [
2459
            'selectedTypes' => $selectedTypes,
2460
            'kept_types' => array_keys($out),
2461
        ]);
2462
2463
        return $out;
2464
    }
2465
2466
    /**
2467
     * Build link tree (Category → Link) for the UI.
2468
     * Categories are not selectable; links are leaves (item_count = 0).
2469
     */
2470
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
2471
    {
2472
        $this->logDebug('[buildLinkTreeForVue] start');
2473
2474
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2475
        $catRaw  = $res['link_category'] ?? $res['Link_Category'] ?? [];
2476
        $linkRaw = $res['link']          ?? $res['Link']          ?? [];
2477
2478
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
2479
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
2480
            'links'      => \is_array($linkRaw) ? \count($linkRaw) : 0,
2481
        ]);
2482
2483
        $cats = [];
2484
        foreach ($catRaw as $id => $obj) {
2485
            $id = (int) $id;
2486
            if ($id <= 0 || !\is_object($obj)) { continue; }
2487
            $e = $this->objectEntity($obj);
2488
            $label = $this->resolveItemLabel('link_category', $e, $id);
2489
2490
            $cats[$id] = [
2491
                'id'           => $id,
2492
                'type'         => 'link_category',
2493
                'label'        => $label !== '' ? $label : ('Category #'.$id),
2494
                'selectable'   => false,
2495
                'items'        => [],
2496
                'has_children' => false,
2497
                'item_count'   => 0,
2498
            ];
2499
        }
2500
2501
        // Virtual "Uncategorized"
2502
        $uncatKey = -9999;
2503
        if (!isset($cats[$uncatKey])) {
2504
            $cats[$uncatKey] = [
2505
                'id'           => $uncatKey,
2506
                'type'         => 'link_category',
2507
                'label'        => 'Uncategorized',
2508
                'selectable'   => false,
2509
                'items'        => [],
2510
                '_virtual'     => true,
2511
                'has_children' => false,
2512
                'item_count'   => 0,
2513
            ];
2514
        }
2515
2516
        // Assign links to categories
2517
        foreach ($linkRaw as $id => $obj) {
2518
            $id = (int) $id;
2519
            if ($id <= 0 || !\is_object($obj)) { continue; }
2520
            $e = $this->objectEntity($obj);
2521
2522
            $cid = (int) ($e->category_id ?? 0);
2523
            if (!isset($cats[$cid])) { $cid = $uncatKey; }
2524
2525
            $cats[$cid]['items'][] = [
2526
                'id'         => $id,
2527
                'type'       => 'link',
2528
                'label'      => $this->resolveItemLabel('link', $e, $id),
2529
                'extra'      => $this->buildExtra('link', $e) ?: new \stdClass(),
2530
                'selectable' => true,
2531
                'item_count' => 0,
2532
            ];
2533
        }
2534
2535
        // Drop empty virtual category, sort, and finalize UI hints
2536
        $catNodes = array_values(array_filter($cats, static function ($c) {
2537
            if (!empty($c['_virtual']) && empty($c['items'])) { return false; }
2538
            return true;
2539
        }));
2540
2541
        foreach ($catNodes as &$c) {
2542
            if (!empty($c['items'])) {
2543
                usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2544
            }
2545
            $c['has_children'] = !empty($c['items']);
2546
            $c['item_count']   = \count($c['items'] ?? []);
2547
        }
2548
        unset($c);
2549
2550
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2551
2552
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
2553
2554
        return [
2555
            'type'  => 'link',
2556
            'title' => $groupTitle,
2557
            'items' => $catNodes,
2558
        ];
2559
    }
2560
2561
    /**
2562
     * Leaves only the items selected by the UI in $course->resources.
2563
     * Expects $selected with the following form:
2564
     * [
2565
     * "documents" => ["123" => true, "124" => true],
2566
     * "links" => ["7" => true],
2567
     * "quiz" => ["45" => true],
2568
     * ...
2569
     * ].
2570
     */
2571
    private function filterCourseResources(object $course, array $selected): void
2572
    {
2573
        if (!isset($course->resources) || !\is_array($course->resources)) {
2574
            return;
2575
        }
2576
2577
        $typeMap = [
2578
            'documents' => RESOURCE_DOCUMENT,
2579
            'links' => RESOURCE_LINK,
2580
            'quizzes' => RESOURCE_QUIZ,
2581
            'quiz' => RESOURCE_QUIZ,
2582
            'quiz_questions' => RESOURCE_QUIZQUESTION,
2583
            'surveys' => RESOURCE_SURVEY,
2584
            'survey' => RESOURCE_SURVEY,
2585
            'survey_questions' => RESOURCE_SURVEYQUESTION,
2586
            'announcements' => RESOURCE_ANNOUNCEMENT,
2587
            'events' => RESOURCE_EVENT,
2588
            'course_descriptions' => RESOURCE_COURSEDESCRIPTION,
2589
            'glossary' => RESOURCE_GLOSSARY,
2590
            'wiki' => RESOURCE_WIKI,
2591
            'thematic' => RESOURCE_THEMATIC,
2592
            'attendance' => RESOURCE_ATTENDANCE,
2593
            'works' => RESOURCE_WORK,
2594
            'gradebook' => RESOURCE_GRADEBOOK,
2595
            'learnpaths' => RESOURCE_LEARNPATH,
2596
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
2597
            'tool_intro' => RESOURCE_TOOL_INTRO,
2598
            'forums' => RESOURCE_FORUM,
2599
            'forum' => RESOURCE_FORUM,
2600
            'forum_topic' => RESOURCE_FORUMTOPIC,
2601
            'forum_post' => RESOURCE_FORUMPOST,
2602
        ];
2603
2604
        $allowed = [];
2605
        foreach ($selected as $k => $idsMap) {
2606
            $key = $typeMap[$k] ?? $k;
2607
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
2608
        }
2609
2610
        foreach ($course->resources as $rtype => $bucket) {
2611
            if (!isset($allowed[$rtype])) {
2612
                continue;
2613
            }
2614
            $keep = $allowed[$rtype];
2615
            $filtered = [];
2616
            foreach ((array) $bucket as $id => $obj) {
2617
                $iid = (int) ($obj->source_id ?? $id);
2618
                if (isset($keep[$iid])) {
2619
                    $filtered[$id] = $obj;
2620
                }
2621
            }
2622
            $course->resources[$rtype] = $filtered;
2623
        }
2624
    }
2625
2626
    /**
2627
     * Returns the absolute path of the backupId in the backup directory.
2628
     */
2629
    private function resolveBackupPath(string $backupId): string
2630
    {
2631
        $backupDir = rtrim(CourseArchiver::getBackupDir(), '/');
2632
2633
        return $backupDir.'/'.$backupId;
2634
    }
2635
2636
    /**
2637
     * Heuristic: Does it look like a Moodle package?
2638
     * - If the extension (.mbz, .tgz, .gz) is used, we treat it as Moodle.
2639
     * - If it's a .zip file but CourseArchiver fails or contains "moodle_backup.xml," it's also Moodle.
2640
     */
2641
    private function isLikelyMoodlePackage(string $path, ?Throwable $priorError = null): bool
0 ignored issues
show
Unused Code introduced by
The method isLikelyMoodlePackage() 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...
2642
    {
2643
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2644
        if (\in_array($ext, ['mbz', 'tgz', 'gz'], true)) {
2645
            return true;
2646
        }
2647
        if ('zip' === $ext && $priorError) {
2648
            return true;
2649
        }
2650
2651
        return false;
2652
    }
2653
2654
    /**
2655
     * Load a legacy course from a Chamilo or Moodle backup, testing both readers.
2656
     * - First try CourseArchiver (Chamilo).
2657
     * - If that fails, retry MoodleImport.
2658
     */
2659
    private function loadLegacyCourseForAnyBackup(string $backupId): object
2660
    {
2661
        $path = $this->resolveBackupPath($backupId);
2662
        $this->logDebug('[loadLegacyCourseForAnyBackup] try Chamilo first', ['path' => $path]);
2663
2664
        // Chamilo ZIP (course_info.dat)
2665
        try {
2666
            $course = CourseArchiver::readCourse($backupId, false);
2667
            if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
2668
                throw new RuntimeException('Invalid Chamilo backup structure (empty resources)');
2669
            }
2670
2671
            return $course;
2672
        } catch (Throwable $e) {
2673
            $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo reader failed, will try Moodle', ['err' => $e->getMessage()]);
2674
            $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2675
            if (!\in_array($ext, ['mbz', 'zip', 'gz', 'tgz'], true)) {
2676
                throw $e;
2677
            }
2678
        }
2679
2680
        // Moodle (.mbz/.zip/.tgz)
2681
        $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
2682
        $importer = new MoodleImport(debug: $this->debug);
2683
        $course = $importer->buildLegacyCourseFromMoodleArchive($path);
2684
2685
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
2686
            throw new RuntimeException('Moodle backup contains no importable resources');
2687
        }
2688
2689
        return $course;
2690
    }
2691
2692
    private function normalizeBucketsForRestorer(object $course): void
2693
    {
2694
        if (!isset($course->resources) || !\is_array($course->resources)) {
2695
            return;
2696
        }
2697
2698
        $map = [
2699
            'link' => RESOURCE_LINK,
2700
            'link_category' => RESOURCE_LINKCATEGORY,
2701
            'forum' => RESOURCE_FORUM,
2702
            'forum_category' => RESOURCE_FORUMCATEGORY,
2703
            'forum_topic' => RESOURCE_FORUMTOPIC,
2704
            'forum_post' => RESOURCE_FORUMPOST,
2705
            'thread' => RESOURCE_FORUMTOPIC,
2706
            'post' => RESOURCE_FORUMPOST,
2707
            'document' => RESOURCE_DOCUMENT,
2708
            'quiz' => RESOURCE_QUIZ,
2709
            'exercise_question' => RESOURCE_QUIZQUESTION,
2710
            'survey' => RESOURCE_SURVEY,
2711
            'survey_question' => RESOURCE_SURVEYQUESTION,
2712
            'tool_intro' => RESOURCE_TOOL_INTRO,
2713
        ];
2714
2715
        $res = $course->resources;
2716
        foreach ($map as $from => $to) {
2717
            if (isset($res[$from]) && \is_array($res[$from])) {
2718
                if (!isset($res[$to])) {
2719
                    $res[$to] = $res[$from];
2720
                }
2721
                unset($res[$from]);
2722
            }
2723
        }
2724
2725
        $course->resources = $res;
2726
    }
2727
2728
    /**
2729
     * Read import_source without depending on filtered resources.
2730
     * Falls back to $course->info['__import_source'] if needed.
2731
     */
2732
    private function getImportSource(object $course): string
2733
    {
2734
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
2735
        if ('' !== $src) {
2736
            return $src;
2737
        }
2738
2739
        // Fallbacks (defensive)
2740
        return strtolower((string) ($course->info['__import_source'] ?? ''));
2741
    }
2742
2743
    /**
2744
     * Builds a CC 1.3 preview from the legacy Course (only the implemented one).
2745
     * Returns a structure intended for rendering/committing before the actual export.
2746
     */
2747
    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...
2748
    {
2749
        $ims = [
2750
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document']
2751
            'resources' => [
2752
                'webcontent' => [],
2753
            ],
2754
            'counts' => ['files' => 0, 'folders' => 0],
2755
            'defaultSelection' => [
2756
                'documents' => [],
2757
            ],
2758
        ];
2759
2760
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2761
        $docKey = null;
2762
2763
        foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) {
2764
            if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) {
2765
                $docKey = $cand;
2766
                break;
2767
            }
2768
        }
2769
        if (!$docKey) {
2770
            return $ims;
2771
        }
2772
2773
        foreach ($res[$docKey] as $iid => $wrap) {
2774
            if (!\is_object($wrap)) {
2775
                continue;
2776
            }
2777
            $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
2778
2779
            $rawPath = (string) ($e->path ?? $e->full_path ?? '');
2780
            if ('' === $rawPath) {
2781
                continue;
2782
            }
2783
            $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/');
2784
2785
            $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2786
            $isDir = ($fileType === 'folder') || (substr($rawPath, -1) === '/');
2787
2788
            $title = (string) ($e->title ?? $wrap->name ?? basename($rel));
2789
            $ims['resources']['webcontent'][] = [
2790
                'id' => (int) $iid,
2791
                'cc_type' => 'webcontent',
2792
                'title' => $title !== '' ? $title : basename($rel),
2793
                'rel' => $rel,
2794
                'is_dir' => $isDir,
2795
                'would_be_manifest_entry' => !$isDir,
2796
            ];
2797
2798
            if (!$isDir) {
2799
                $ims['defaultSelection']['documents'][(int) $iid] = true;
2800
                $ims['counts']['files']++;
2801
            } else {
2802
                $ims['counts']['folders']++;
2803
            }
2804
        }
2805
2806
        return $ims;
2807
    }
2808
2809
    /**
2810
     * Expand category selections (link/forum) to their item IDs using a full course snapshot.
2811
     * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel.
2812
     */
2813
    private function expandCc13SelectionFromCategories(object $course, array $normSel): array
2814
    {
2815
        $out = [
2816
            'documents' => (array) ($normSel['documents'] ?? []),
2817
            'links'     => (array) ($normSel['links']     ?? []),
2818
            'forums'    => (array) ($normSel['forums']    ?? []),
2819
        ];
2820
2821
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2822
2823
        // Link categories → link IDs
2824
        if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) {
2825
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true);
2826
            $links   = $res['link'] ?? $res['Link'];
2827
            foreach ($links as $lid => $wrap) {
2828
                if (!\is_object($wrap)) { continue; }
2829
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
2830
                $cid = (string) (int) ($e->category_id ?? 0);
2831
                if (isset($selCats[$cid])) { $out['links'][(string)$lid] = true; }
2832
            }
2833
        }
2834
2835
        // Forum categories → forum IDs
2836
        if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) {
2837
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true);
2838
            $forums  = $res['forum'] ?? $res['Forum'];
2839
            foreach ($forums as $fid => $wrap) {
2840
                if (!\is_object($wrap)) { continue; }
2841
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
2842
                $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
2843
                if (isset($selCats[$cid])) { $out['forums'][(string)$fid] = true; }
2844
            }
2845
        }
2846
2847
        return $out;
2848
    }
2849
}
2850