Passed
Pull Request — master (#6894)
by
unknown
09:02
created

CourseMaintenanceController::buildLinkTreeForVue()   D

Complexity

Conditions 18
Paths 192

Size

Total Lines 92
Code Lines 57

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
eloc 57
c 0
b 0
f 0
nc 192
nop 2
dl 0
loc 92
rs 4.1

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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

488
            error_log('$legacyCourse :::: './** @scrutinizer ignore-type */ print_r($legacyCourse, true));
Loading history...
489
490
            $restorer = new CourseRestorer($legacyCourse);
491
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
492
            if (method_exists($restorer, 'setDebug')) {
493
                $restorer->setDebug($this->debug);
494
            }
495
            $restorer->restore();
496
497
            $dest = api_get_course_info();
498
            $redirectUrl = \sprintf('/course/%d/home', (int) $dest['real_id']);
499
500
            return $this->json([
501
                'ok' => true,
502
                'message' => 'Copy finished',
503
                'redirectUrl' => $redirectUrl,
504
            ]);
505
        } catch (Throwable $e) {
506
            return $this->json([
507
                'error' => 'Copy failed: '.$e->getMessage(),
508
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
509
            ], 500);
510
        }
511
    }
512
513
    #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])]
514
    public function recycleOptions(int $node, Request $req): JsonResponse
515
    {
516
        $this->setDebugFromRequest($req);
517
518
        // current course only
519
        $defaults = [
520
            'recycleOption' => 'select_items', // 'full_recycle' | 'select_items'
521
            'confirmNeeded' => true,           // show code-confirm input when full
522
        ];
523
524
        return $this->json(['defaults' => $defaults]);
525
    }
526
527
    #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])]
528
    public function recycleResources(int $node, Request $req): JsonResponse
529
    {
530
        $this->setDebugFromRequest($req);
531
532
        // Build legacy Course from CURRENT course (not “source”)
533
        $cb = new CourseBuilder();
534
        $cb->set_tools_to_build([
535
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
536
            'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki',
537
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
538
        ]);
539
        $course = $cb->build(0, api_get_course_id());
540
541
        $tree = $this->buildResourceTreeForVue($course);
542
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
543
544
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
545
    }
546
547
    #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])]
548
    public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse
549
    {
550
        try {
551
            $p = json_decode($req->getContent() ?: '{}', true);
552
            $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items'
553
            $resourcesMap = (array) ($p['resources'] ?? []);
554
            $confirmCode = (string) ($p['confirm'] ?? '');
555
556
            $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items';
557
558
            if ('full_backup' === $type) {
559
                if ($confirmCode !== api_get_course_id()) {
560
                    return $this->json(['error' => 'Course code confirmation mismatch'], 400);
561
                }
562
            } else {
563
                if (empty($resourcesMap)) {
564
                    return $this->json(['error' => 'No resources selected'], 400);
565
                }
566
            }
567
568
            $courseCode = api_get_course_id();
569
            $courseInfo = api_get_course_info($courseCode);
570
            $courseId = (int) ($courseInfo['real_id'] ?? 0);
571
            if ($courseId <= 0) {
572
                return $this->json(['error' => 'Invalid course id'], 400);
573
            }
574
575
            $recycler = new CourseRecycler(
576
                $em,
577
                $courseCode,
578
                $courseId
579
            );
580
581
            $recycler->recycle($type, $resourcesMap);
582
583
            return $this->json([
584
                'ok' => true,
585
                'message' => 'Recycle finished',
586
            ]);
587
        } catch (Throwable $e) {
588
            return $this->json([
589
                'error' => 'Recycle failed: '.$e->getMessage(),
590
            ], 500);
591
        }
592
    }
593
594
    #[Route('/delete', name: 'delete', methods: ['POST'])]
595
    public function deleteCourse(int $node, Request $req): JsonResponse
596
    {
597
        // Basic permission gate (adjust roles to your policy if needed)
598
        if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_TEACHER') && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')) {
599
            return $this->json(['error' => 'You are not allowed to delete this course'], 403);
600
        }
601
602
        try {
603
            $payload = json_decode($req->getContent() ?: '{}', true);
604
            $confirm = trim((string) ($payload['confirm'] ?? ''));
605
606
            if ('' === $confirm) {
607
                return $this->json(['error' => 'Missing confirmation value'], 400);
608
            }
609
610
            // Current course
611
            $courseInfo = api_get_course_info();
612
            if (empty($courseInfo)) {
613
                return $this->json(['error' => 'Unable to resolve current course'], 400);
614
            }
615
616
            $officialCode = (string) ($courseInfo['official_code'] ?? '');
617
            $runtimeCode = (string) api_get_course_id();                 // often equals official code
618
            $sysCode = (string) ($courseInfo['sysCode'] ?? '');       // used by legacy delete
619
620
            if ('' === $sysCode) {
621
                return $this->json(['error' => 'Invalid course system code'], 400);
622
            }
623
624
            // Accept either official_code or api_get_course_id() as confirmation
625
            $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm);
626
            if (!$matches) {
627
                return $this->json(['error' => 'Course code confirmation mismatch'], 400);
628
            }
629
630
            // Legacy delete (removes course data + unregisters members in this course)
631
            // Throws on failure or returns void
632
            CourseManager::delete_course($sysCode);
633
634
            // Best-effort cleanup of legacy course session flags
635
            try {
636
                $ses = $req->getSession();
637
                $ses?->remove('_cid');
638
                $ses?->remove('_real_cid');
639
            } catch (Throwable) {
640
                // swallow — not critical
641
            }
642
643
            // Decide where to send the user afterwards
644
            // You can use '/index.php' or a landing page
645
            $redirectUrl = '/index.php';
646
647
            return $this->json([
648
                'ok' => true,
649
                'message' => 'Course deleted successfully',
650
                'redirectUrl' => $redirectUrl,
651
            ]);
652
        } catch (Throwable $e) {
653
            return $this->json([
654
                'error' => 'Failed to delete course: '.$e->getMessage(),
655
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
656
            ], 500);
657
        }
658
    }
659
660
    #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])]
661
    public function moodleExportOptions(int $node, Request $req, UserRepository $users): JsonResponse
662
    {
663
        $defaults = [
664
            'moodleVersion' => '4',
665
            'scope' => 'full',
666
            'admin' => $users->getDefaultAdminForExport(),
667
        ];
668
669
        return $this->json([
670
            'versions' => [
671
                ['value' => '3', 'label' => 'Moodle 3.x'],
672
                ['value' => '4', 'label' => 'Moodle 4.x'],
673
            ],
674
            'defaults' => $defaults,
675
        ]);
676
    }
677
678
    #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])]
679
    public function moodleExportResources(int $node, Request $req): JsonResponse
680
    {
681
        $this->setDebugFromRequest($req);
682
683
        // Build legacy Course from CURRENT course (same approach as recycle)
684
        $cb = new CourseBuilder();
685
        $cb->set_tools_to_build([
686
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
687
            'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki',
688
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
689
        ]);
690
        $course = $cb->build(0, api_get_course_id());
691
692
        $tree = $this->buildResourceTreeForVue($course);
693
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
694
695
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
696
    }
697
698
    #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])]
699
    public function moodleExportExecute(int $node, Request $req, UserRepository $users): JsonResponse|BinaryFileResponse
700
    {
701
        $this->setDebugFromRequest($req);
702
703
        $p = json_decode($req->getContent() ?: '{}', true);
704
        $moodleVersion = (string) ($p['moodleVersion'] ?? '4');   // '3' | '4'
705
        $scope = (string) ($p['scope'] ?? 'full');        // 'full' | 'selected'
706
        $adminId = (int) ($p['adminId'] ?? 0);
707
        $adminLogin = trim((string) ($p['adminLogin'] ?? ''));
708
        $adminEmail = trim((string) ($p['adminEmail'] ?? ''));
709
        $selected = (array) ($p['resources'] ?? []);
710
711
        if (!\in_array($moodleVersion, ['3', '4'], true)) {
712
            return $this->json(['error' => 'Unsupported Moodle version'], 400);
713
        }
714
        if ('selected' === $scope && empty($selected)) {
715
            return $this->json(['error' => 'No resources selected'], 400);
716
        }
717
718
        if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
719
            $adm = $users->getDefaultAdminForExport();
720
            $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
721
            $adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
722
            $adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? '[email protected]');
723
        }
724
725
        // Build legacy Course from CURRENT course (same approach as recycle)
726
        $cb = new CourseBuilder();
727
        $cb->set_tools_to_build([
728
            'documents', 'links', 'quizzes', 'quiz_questions', 'surveys', 'survey_questions',
729
            'announcements', 'events', 'course_descriptions', 'glossary', 'wiki', 'thematic',
730
            'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', 'tool_intro',
731
            'forums',
732
        ]);
733
        $course = $cb->build(0, api_get_course_id());
734
735
        // IMPORTANT: when scope === 'selected', use the same robust selection filter as copy-course
736
        if ('selected' === $scope) {
737
            // This method trims buckets to only selected items and pulls needed deps (LP/quiz/survey)
738
            $course = $this->filterLegacyCourseBySelection($course, $selected);
739
740
            // Safety guard: fail if nothing remains after filtering
741
            if (empty($course->resources) || !\is_array($course->resources)) {
742
                return $this->json(['error' => 'Selection produced no resources to export'], 400);
743
            }
744
        }
745
746
        try {
747
            // Pass selection flag to exporter so it does NOT re-hydrate from a complete snapshot.
748
            $selectionMode = ('selected' === $scope);
749
            $exporter = new MoodleExport($course, $selectionMode);
750
            $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail);
751
752
            $courseId = api_get_course_id();
753
            $exportDir = 'moodle_export_'.date('Ymd_His');
754
            $versionNum = ('3' === $moodleVersion) ? 3 : 4;
755
756
            $mbzPath = $exporter->export($courseId, $exportDir, $versionNum);
757
758
            $resp = new BinaryFileResponse($mbzPath);
759
            $resp->setContentDisposition(
760
                ResponseHeaderBag::DISPOSITION_ATTACHMENT,
761
                basename($mbzPath)
762
            );
763
764
            return $resp;
765
        } catch (Throwable $e) {
766
            return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
767
        }
768
    }
769
770
    #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])]
771
    public function cc13ExportOptions(int $node, Request $req): JsonResponse
772
    {
773
        $this->setDebugFromRequest($req);
774
775
        return $this->json([
776
            'defaults' => [
777
                'scope' => 'full', // 'full' | 'selected'
778
            ],
779
            'message' => 'Common Cartridge 1.3 export is under construction. You can already pick items and submit.',
780
        ]);
781
    }
782
783
    #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])]
784
    public function cc13ExportResources(int $node, Request $req): JsonResponse
785
    {
786
        $this->setDebugFromRequest($req);
787
788
        $cb = new CourseBuilder();
789
        $cb->set_tools_to_build([
790
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
791
            'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki',
792
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
793
        ]);
794
        $course = $cb->build(0, api_get_course_id());
795
796
        $tree = $this->buildResourceTreeForVue($course);
797
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
798
799
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
800
    }
801
802
    #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])]
803
    public function cc13ExportExecute(int $node, Request $req): JsonResponse
804
    {
805
        $this->setDebugFromRequest($req);
806
807
        $p = json_decode($req->getContent() ?: '{}', true);
808
        $scope = (string) ($p['scope'] ?? 'full');   // 'full' | 'selected'
809
        $resources = (array) ($p['resources'] ?? []);
810
811
        if (!\in_array($scope, ['full', 'selected'], true)) {
812
            return $this->json(['error' => 'Unsupported scope'], 400);
813
        }
814
        if ('selected' === $scope && empty($resources)) {
815
            return $this->json(['error' => 'No resources selected'], 400);
816
        }
817
818
        // TODO: Generate IMS CC 1.3 cartridge (.imscc or .zip)
819
        // For now, return an informative 202 “under construction”.
820
        return new JsonResponse([
821
            'ok' => false,
822
            'message' => 'Common Cartridge 1.3 export is under construction. No file was generated.',
823
            // 'downloadUrl' => null, // set when implemented
824
        ], 202);
825
    }
826
827
    #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])]
828
    public function cc13Import(int $node, Request $req): JsonResponse
829
    {
830
        $this->setDebugFromRequest($req);
831
832
        $file = $req->files->get('file');
833
        if (!$file) {
834
            return $this->json(['error' => 'Missing file'], 400);
835
        }
836
        $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION));
837
        if (!\in_array($ext, ['imscc', 'zip'], true)) {
838
            return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 400);
839
        }
840
841
        // TODO: Parse/restore CC 1.3. For now, just acknowledge.
842
        return $this->json([
843
            'ok' => true,
844
            'message' => 'CC 1.3 import endpoint is under construction. File received successfully.',
845
        ]);
846
    }
847
848
    // --------------------------------------------------------------------------------
849
    // Helpers to build the Vue-ready resource tree
850
    // --------------------------------------------------------------------------------
851
852
    /**
853
     * Copies the dependencies (document, link, quiz, etc.) to $course->resources
854
     * that reference the selected LearnPaths, taking the items from the full snapshot.
855
     *
856
     * It doesn't break anything if something is missing or comes in a different format: it's defensive.
857
     */
858
    private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void
859
    {
860
        if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) {
861
            return;
862
        }
863
864
        $depTypes = [
865
            'document', 'link', 'quiz', 'work', 'survey',
866
            'Forum_Category', 'forum', 'thread', 'post',
867
            'Exercise_Question', 'survey_question', 'Link_Category',
868
        ];
869
870
        $need = [];
871
        $addNeed = function (string $type, $id) use (&$need): void {
872
            $t = (string) $type;
873
            $i = is_numeric($id) ? (int) $id : (string) $id;
874
            if ('' === $i || 0 === $i) {
875
                return;
876
            }
877
            $need[$t] ??= [];
878
            $need[$t][$i] = true;
879
        };
880
881
        foreach ($course->resources['learnpath'] as $lpId => $lpWrap) {
882
            $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap;
883
884
            if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) {
885
                foreach ($lpWrap->linked_resources as $t => $ids) {
886
                    if (!\is_array($ids)) {
887
                        continue;
888
                    }
889
                    foreach ($ids as $rid) {
890
                        $addNeed($t, $rid);
891
                    }
892
                }
893
            }
894
895
            $items = [];
896
            if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) {
897
                $items = $lp->items;
898
            } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) {
899
                $items = $lpWrap->items;
900
            }
901
902
            foreach ($items as $it) {
903
                $ito = \is_object($it) ? $it : (object) $it;
904
905
                if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) {
906
                    foreach ($ito->linked_resources as $t => $ids) {
907
                        if (!\is_array($ids)) {
908
                            continue;
909
                        }
910
                        foreach ($ids as $rid) {
911
                            $addNeed($t, $rid);
912
                        }
913
                    }
914
                }
915
916
                foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) {
917
                    if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) {
918
                        $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field};
919
                        $t = $typeGuess ?: (string) ($ito->type ?? '');
920
                        if ('' !== $t) {
921
                            $addNeed($t, $rid);
922
                        }
923
                    }
924
                }
925
926
                if (!empty($ito->type) && isset($ito->ref)) {
927
                    $addNeed((string) $ito->type, $ito->ref);
928
                }
929
            }
930
        }
931
932
        if (empty($need)) {
933
            $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'];
934
            foreach ($core as $k) {
935
                if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) {
936
                    $course->resources[$k] ??= [];
937
                    if (0 === \count($course->resources[$k])) {
938
                        $course->resources[$k] = $snapshot[$k];
939
                    }
940
                }
941
            }
942
            $this->logDebug('[LP-deps] fallback filled from snapshot', [
943
                '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)),
944
            ]);
945
946
            return;
947
        }
948
949
        foreach ($need as $type => $idMap) {
950
            if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) {
951
                continue;
952
            }
953
954
            $course->resources[$type] ??= [];
955
956
            foreach (array_keys($idMap) as $rid) {
957
                $src = $snapshot[$type][$rid]
958
                    ?? $snapshot[$type][(string) $rid]
959
                    ?? null;
960
961
                if (!$src) {
962
                    continue;
963
                }
964
965
                if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) {
966
                    $course->resources[$type][$rid] = $src;
967
                }
968
            }
969
        }
970
971
        $this->logDebug('[LP-deps] hydrated', [
972
            'types' => array_keys($need),
973
            'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)),
974
        ]);
975
    }
976
977
    /**
978
     * Build a Vue-friendly tree from legacy Course.
979
     */
980
    private function buildResourceTreeForVue(object $course): array
981
    {
982
        if ($this->debug) {
983
            $this->logDebug('[buildResourceTreeForVue] start');
984
        }
985
986
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
987
            ? $course->resources
988
            : [];
989
990
        $legacyTitles = [];
991
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
992
            /** @var array<string,string> $legacyTitles */
993
            $legacyTitles = CourseSelectForm::getResourceTitleList();
994
        }
995
        $fallbackTitles = $this->getDefaultTypeTitles();
996
        $skipTypes = $this->getSkipTypeKeys();
997
998
        $tree = [];
999
1000
        // Forums block
1001
        $hasForumData =
1002
            (!empty($resources['forum']) || !empty($resources['Forum']))
1003
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1004
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1005
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1006
1007
        if ($hasForumData) {
1008
            $tree[] = $this->buildForumTreeForVue(
1009
                $course,
1010
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1011
            );
1012
            $skipTypes['forum'] = true;
1013
            $skipTypes['forum_category'] = true;
1014
            $skipTypes['forum_topic'] = true;
1015
            $skipTypes['forum_post'] = true;
1016
            $skipTypes['thread'] = true;
1017
            $skipTypes['post'] = true;
1018
        }
1019
1020
        // Links block (Category → Link)
1021
        $hasLinkData =
1022
            (!empty($resources['link']) || !empty($resources['Link']))
1023
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1024
1025
        if ($hasLinkData) {
1026
            $tree[] = $this->buildLinkTreeForVue(
1027
                $course,
1028
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1029
            );
1030
            // Prevent generic loop from adding separate "link" and "link_category" groups
1031
            $skipTypes['link'] = true;
1032
            $skipTypes['link_category'] = true;
1033
        }
1034
1035
        // Other tools
1036
        foreach ($resources as $rawType => $items) {
1037
            if (!\is_array($items) || empty($items)) {
1038
                continue;
1039
            }
1040
            $typeKey = $this->normalizeTypeKey($rawType);
1041
            if (isset($skipTypes[$typeKey])) {
1042
                continue;
1043
            }
1044
1045
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1046
            $group = [
1047
                'type' => $typeKey,
1048
                'title' => (string) $groupTitle,
1049
                'items' => [],
1050
            ];
1051
1052
            if ('gradebook' === $typeKey) {
1053
                $group['items'][] = [
1054
                    'id' => 'all',
1055
                    'label' => 'Gradebook (all)',
1056
                    'extra' => new stdClass(),
1057
                    'selectable' => true,
1058
                ];
1059
                $tree[] = $group;
1060
1061
                continue;
1062
            }
1063
1064
            foreach ($items as $id => $obj) {
1065
                if (!\is_object($obj)) {
1066
                    continue;
1067
                }
1068
1069
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1070
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1071
                    continue;
1072
                }
1073
1074
                if (!$this->isSelectableItem($typeKey, $obj)) {
1075
                    continue;
1076
                }
1077
1078
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1079
                if ('document' === $typeKey) {
1080
                    $e = $this->objectEntity($obj);
1081
                    $rawPath = (string) ($e->path ?? '');
1082
                    if ('' !== $rawPath) {
1083
                        $rel = ltrim($rawPath, '/');
1084
                        $rel = preg_replace('~^document/?~', '', $rel);
1085
                        $filetype = (string) ($e->filetype ?? $e->file_type ?? '');
1086
                        if ('folder' === $filetype) {
1087
                            $rel = rtrim($rel, '/').'/';
1088
                        }
1089
                        if ('' !== $rel) {
1090
                            $label = $rel;
1091
                        }
1092
                    }
1093
                }
1094
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1095
                    $label = $idKey;
1096
                }
1097
1098
                $extra = $this->buildExtra($typeKey, $obj);
1099
1100
                $group['items'][] = [
1101
                    'id' => $idKey,
1102
                    'label' => $label,
1103
                    'extra' => $extra ?: new stdClass(),
1104
                    'selectable' => true,
1105
                ];
1106
            }
1107
1108
            if (!empty($group['items'])) {
1109
                usort(
1110
                    $group['items'],
1111
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1112
                );
1113
                $tree[] = $group;
1114
            }
1115
        }
1116
1117
        // Preferred order
1118
        $preferredOrder = [
1119
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
1120
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook',
1121
        ];
1122
        usort($tree, static function ($a, $b) use ($preferredOrder) {
1123
            $ia = array_search($a['type'], $preferredOrder, true);
1124
            $ib = array_search($b['type'], $preferredOrder, true);
1125
            if (false !== $ia && false !== $ib) {
1126
                return $ia <=> $ib;
1127
            }
1128
            if (false !== $ia) {
1129
                return -1;
1130
            }
1131
            if (false !== $ib) {
1132
                return 1;
1133
            }
1134
1135
            return strcasecmp($a['title'], $b['title']);
1136
        });
1137
1138
        if ($this->debug) {
1139
            $this->logDebug(
1140
                '[buildResourceTreeForVue] end groups',
1141
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? [])], $tree)
1142
            );
1143
        }
1144
1145
        return $tree;
1146
    }
1147
1148
    /**
1149
     * Build forum tree (Category → Forum → Topic).
1150
     */
1151
    private function buildForumTreeForVue(object $course, string $groupTitle): array
1152
    {
1153
        $this->logDebug('[buildForumTreeForVue] start');
1154
1155
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
1156
1157
        // Buckets (accept legacy casings / aliases)
1158
        $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
1159
        $forumRaw = $res['forum'] ?? $res['Forum'] ?? [];
1160
        $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []);
1161
        $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []);
1162
1163
        $this->logDebug('[buildForumTreeForVue] raw counts', [
1164
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
1165
            'forums' => \is_array($forumRaw) ? \count($forumRaw) : 0,
1166
            'topics' => \is_array($topicRaw) ? \count($topicRaw) : 0,
1167
            'posts' => \is_array($postRaw) ? \count($postRaw) : 0,
1168
        ]);
1169
1170
        // Classifiers (defensive)
1171
        $isForum = function (object $o): bool {
1172
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1173
            if (isset($e->forum_title) && \is_string($e->forum_title)) {
1174
                return true;
1175
            }
1176
            if (isset($e->default_view) || isset($e->allow_anonymous)) {
1177
                return true;
1178
            }
1179
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) {
1180
                return true;
1181
            }
1182
1183
            return false;
1184
        };
1185
        $isTopic = function (object $o): bool {
1186
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1187
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) {
1188
                return true;
1189
            }
1190
            if (isset($e->forum_id) && !isset($e->forum_title)) {
1191
                return true;
1192
            }
1193
1194
            return false;
1195
        };
1196
        $getForumCategoryId = function (object $forum): int {
1197
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
1198
            $cid = (int) ($e->forum_category ?? 0);
1199
            if ($cid <= 0) {
1200
                $cid = (int) ($e->forum_category_id ?? 0);
1201
            }
1202
            if ($cid <= 0) {
1203
                $cid = (int) ($e->category_id ?? 0);
1204
            }
1205
1206
            return $cid;
1207
        };
1208
1209
        // Categories
1210
        $cats = [];
1211
        foreach ($catRaw as $id => $obj) {
1212
            $id = (int) $id;
1213
            if ($id <= 0 || !\is_object($obj)) {
1214
                continue;
1215
            }
1216
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
1217
            $cats[$id] = [
1218
                'id' => $id,
1219
                'type' => 'forum_category',
1220
                'label' => $label,
1221
                'selectable' => false,
1222
                'children' => [],
1223
            ];
1224
        }
1225
        $uncatKey = -9999;
1226
        if (!isset($cats[$uncatKey])) {
1227
            $cats[$uncatKey] = [
1228
                'id' => $uncatKey,
1229
                'type' => 'forum_category',
1230
                'label' => 'Uncategorized',
1231
                'selectable' => false,
1232
                'children' => [],
1233
                '_virtual' => true,
1234
            ];
1235
        }
1236
1237
        // Forums
1238
        $forums = [];
1239
        foreach ($forumRaw as $id => $obj) {
1240
            $id = (int) $id;
1241
            if ($id <= 0 || !\is_object($obj)) {
1242
                continue;
1243
            }
1244
            if (!$isForum($obj)) {
1245
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bag', ['id' => $id]);
1246
1247
                continue;
1248
            }
1249
            $forums[$id] = $this->objectEntity($obj);
1250
        }
1251
1252
        // Topics + post counts
1253
        $topics = [];
1254
        $postCountByTopic = [];
1255
        foreach ($topicRaw as $id => $obj) {
1256
            $id = (int) $id;
1257
            if ($id <= 0 || !\is_object($obj)) {
1258
                continue;
1259
            }
1260
            if ($isForum($obj) && !$isTopic($obj)) {
1261
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bag; skipping', ['id' => $id]);
1262
1263
                continue;
1264
            }
1265
            if (!$isTopic($obj)) {
1266
                $this->logDebug('[buildForumTreeForVue] skipped non-topic in topic bag', ['id' => $id]);
1267
1268
                continue;
1269
            }
1270
            $topics[$id] = $this->objectEntity($obj);
1271
        }
1272
        foreach ($postRaw as $id => $obj) {
1273
            $id = (int) $id;
1274
            if ($id <= 0 || !\is_object($obj)) {
1275
                continue;
1276
            }
1277
            $e = $this->objectEntity($obj);
1278
            $tid = (int) ($e->thread_id ?? 0);
1279
            if ($tid > 0) {
1280
                $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1;
1281
            }
1282
        }
1283
1284
        // Forums → attach topics
1285
        // Forums → attach topics
1286
        foreach ($forums as $fid => $f) {
1287
            $catId = $getForumCategoryId($f);
1288
            if (!isset($cats[$catId])) {
1289
                $catId = $uncatKey;
1290
            }
1291
1292
            // Build the forum node first (children will be appended below)
1293
            $forumNode = [
1294
                'id' => $fid,
1295
                'type' => 'forum',
1296
                'label' => $this->resolveItemLabel('forum', $f, $fid),
1297
                'extra' => $this->buildExtra('forum', $f) ?: new stdClass(),
1298
                'selectable' => true,
1299
                'children' => [],
1300
                // UI hints (do not affect structure)
1301
                'has_children' => false,  // will become true if a topic is attached
1302
                'ui_depth' => 2,      // category=1, forum=2, topic=3 (purely informational)
1303
            ];
1304
1305
            foreach ($topics as $tid => $t) {
1306
                if ((int) ($t->forum_id ?? 0) !== $fid) {
1307
                    continue;
1308
                }
1309
1310
                $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
1311
                $date = (string) ($t->thread_date ?? '');
1312
                $nPosts = (int) ($postCountByTopic[$tid] ?? 0);
1313
1314
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
1315
                $meta = [];
1316
                if ('' !== $author) {
1317
                    $meta[] = $author;
1318
                }
1319
                if ('' !== $date) {
1320
                    $meta[] = $date;
1321
                }
1322
                if ($meta) {
1323
                    $topicLabel .= ' ('.implode(', ', $meta).')';
1324
                }
1325
                if ($nPosts > 0) {
1326
                    $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's');
1327
                }
1328
1329
                $forumNode['children'][] = [
1330
                    'id' => $tid,
1331
                    'type' => 'forum_topic',
1332
                    'label' => $topicLabel,
1333
                    'extra' => new stdClass(),
1334
                    'selectable' => true,
1335
                    'ui_depth' => 3,
1336
                ];
1337
            }
1338
1339
            // sort topics (if any) and mark has_children for UI
1340
            if ($forumNode['children']) {
1341
                usort($forumNode['children'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1342
                $forumNode['has_children'] = true; // <- tell UI to show a reserved toggle space
1343
            }
1344
1345
            $cats[$catId]['children'][] = $forumNode;
1346
        }
1347
1348
        // Drop empty virtual category and sort forums per category
1349
        $catNodes = array_values(array_filter($cats, static function ($c) {
1350
            if (!empty($c['_virtual']) && empty($c['children'])) {
1351
                return false;
1352
            }
1353
1354
            return true;
1355
        }));
1356
1357
        // --------- FLATTEN STRAY FORUMS (defensive) ----------
1358
        foreach ($catNodes as &$cat) {
1359
            if (empty($cat['children'])) {
1360
                continue;
1361
            }
1362
1363
            $lift = [];           // forums to lift to category level
1364
            foreach ($cat['children'] as $idx => &$forumNode) {
1365
                if (($forumNode['type'] ?? '') !== 'forum') {
1366
                    continue;
1367
                }
1368
                if (empty($forumNode['children'])) {
1369
                    continue;
1370
                }
1371
1372
                // scan children and lift any forum wrongly nested inside
1373
                $keepChildren = [];
1374
                foreach ($forumNode['children'] as $child) {
1375
                    if (($child['type'] ?? '') === 'forum') {
1376
                        // move this stray forum up to category level
1377
                        $lift[] = $child;
1378
                        $this->logDebug('[buildForumTreeForVue] flatten: lifted stray forum from inside another forum', [
1379
                            'parent_forum_id' => $forumNode['id'] ?? null,
1380
                            'lifted_forum_id' => $child['id'] ?? null,
1381
                            'cat_id' => $cat['id'] ?? null,
1382
                        ]);
1383
                    } else {
1384
                        $keepChildren[] = $child; // keep real topics
1385
                    }
1386
                }
1387
                $forumNode['children'] = $keepChildren;
1388
            }
1389
            unset($forumNode);
1390
1391
            // Append lifted forums as siblings (top-level under the category)
1392
            if ($lift) {
1393
                foreach ($lift as $n) {
1394
                    $cat['children'][] = $n;
1395
                }
1396
            }
1397
1398
            // sort forums at category level
1399
            usort($cat['children'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1400
        }
1401
        unset($cat);
1402
        // --------- /FLATTEN STRAY FORUMS ----------
1403
1404
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
1405
1406
        return [
1407
            'type' => 'forum',
1408
            'title' => $groupTitle,
1409
            'items' => $catNodes,
1410
        ];
1411
    }
1412
1413
    /**
1414
     * Normalize a raw type to a lowercase key.
1415
     */
1416
    private function normalizeTypeKey(int|string $raw): string
1417
    {
1418
        if (\is_int($raw)) {
1419
            return (string) $raw;
1420
        }
1421
1422
        $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw));
1423
1424
        $map = [
1425
            'forum_category' => 'forum_category',
1426
            'forumtopic' => 'forum_topic',
1427
            'forum_topic' => 'forum_topic',
1428
            'forum_post' => 'forum_post',
1429
            'thread' => 'forum_topic',
1430
            'post' => 'forum_post',
1431
            'exercise_question' => 'exercise_question',
1432
            'surveyquestion' => 'survey_question',
1433
            'surveyinvitation' => 'survey_invitation',
1434
            'survey' => 'survey',
1435
            'link_category' => 'link_category',
1436
            'coursecopylearnpath' => 'learnpath',
1437
            'coursecopytestcategory' => 'test_category',
1438
            'coursedescription' => 'course_description',
1439
            'session_course' => 'session_course',
1440
            'gradebookbackup' => 'gradebook',
1441
            'scormdocument' => 'scorm',
1442
            'tool/introduction' => 'tool_intro',
1443
            'tool_introduction' => 'tool_intro',
1444
        ];
1445
1446
        return $map[$s] ?? $s;
1447
    }
1448
1449
    /**
1450
     * Keys to skip as top-level groups in UI.
1451
     *
1452
     * @return array<string,bool>
1453
     */
1454
    private function getSkipTypeKeys(): array
1455
    {
1456
        return [
1457
            'forum_category' => true,
1458
            'forum_topic' => true,
1459
            'forum_post' => true,
1460
            'thread' => true,
1461
            'post' => true,
1462
            'exercise_question' => true,
1463
            'survey_question' => true,
1464
            'survey_invitation' => true,
1465
            'session_course' => true,
1466
            'scorm' => true,
1467
            'asset' => true,
1468
            'link_category' => true,
1469
        ];
1470
    }
1471
1472
    /**
1473
     * Default labels for groups.
1474
     *
1475
     * @return array<string,string>
1476
     */
1477
    private function getDefaultTypeTitles(): array
1478
    {
1479
        return [
1480
            'announcement' => 'Announcements',
1481
            'document' => 'Documents',
1482
            'glossary' => 'Glossaries',
1483
            'calendar_event' => 'Calendar events',
1484
            'event' => 'Calendar events',
1485
            'link' => 'Links',
1486
            'course_description' => 'Course descriptions',
1487
            'learnpath' => 'Parcours',
1488
            'learnpath_category' => 'Learning path categories',
1489
            'forum' => 'Forums',
1490
            'forum_category' => 'Forum categories',
1491
            'quiz' => 'Exercices',
1492
            'test_category' => 'Test categories',
1493
            'wiki' => 'Wikis',
1494
            'thematic' => 'Thematics',
1495
            'attendance' => 'Attendances',
1496
            'work' => 'Works',
1497
            'session_course' => 'Session courses',
1498
            'gradebook' => 'Gradebook',
1499
            'scorm' => 'SCORM packages',
1500
            'survey' => 'Surveys',
1501
            'survey_question' => 'Survey questions',
1502
            'survey_invitation' => 'Survey invitations',
1503
            'asset' => 'Assets',
1504
            'tool_intro' => 'Tool introductions',
1505
        ];
1506
    }
1507
1508
    /**
1509
     * Decide if an item is selectable (UI).
1510
     */
1511
    private function isSelectableItem(string $type, object $obj): bool
1512
    {
1513
        if ('document' === $type) {
1514
            return true;
1515
        }
1516
1517
        return true;
1518
    }
1519
1520
    /**
1521
     * Resolve label for an item with fallbacks.
1522
     */
1523
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
1524
    {
1525
        $entity = $this->objectEntity($obj);
1526
1527
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
1528
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
1529
                return trim((string) $entity->{$k});
1530
            }
1531
        }
1532
1533
        if (isset($obj->params) && \is_array($obj->params)) {
1534
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
1535
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
1536
                    return (string) $obj->params[$k];
1537
                }
1538
            }
1539
        }
1540
1541
        switch ($type) {
1542
            case 'document':
1543
                // 1) ruta cruda tal como viene del backup/DB
1544
                $raw = (string) ($entity->path ?? $obj->path ?? '');
1545
                if ('' !== $raw) {
1546
                    // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup
1547
                    $rel = ltrim($raw, '/');
1548
                    $rel = preg_replace('~^document/?~', '', $rel);
1549
1550
                    // 3) carpeta ⇒ que termine con "/"
1551
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
1552
                    if ('folder' === $fileType) {
1553
                        $rel = rtrim($rel, '/').'/';
1554
                    }
1555
1556
                    // 4) si la ruta quedó vacía, usa basename como último recurso
1557
                    return '' !== $rel ? $rel : basename($raw);
1558
                }
1559
1560
                // fallback: título o nombre de archivo
1561
                if (!empty($obj->title)) {
1562
                    return (string) $obj->title;
1563
                }
1564
1565
                break;
1566
1567
            case 'course_description':
1568
                if (!empty($obj->title)) {
1569
                    return (string) $obj->title;
1570
                }
1571
                $t = (int) ($obj->description_type ?? 0);
1572
                $names = [
1573
                    1 => 'Description',
1574
                    2 => 'Objectives',
1575
                    3 => 'Topics',
1576
                    4 => 'Methodology',
1577
                    5 => 'Course material',
1578
                    6 => 'Resources',
1579
                    7 => 'Assessment',
1580
                    8 => 'Custom',
1581
                ];
1582
1583
                return $names[$t] ?? ('#'.$fallbackId);
1584
1585
            case 'announcement':
1586
                if (!empty($obj->title)) {
1587
                    return (string) $obj->title;
1588
                }
1589
1590
                break;
1591
1592
            case 'forum':
1593
                if (!empty($entity->forum_title)) {
1594
                    return (string) $entity->forum_title;
1595
                }
1596
1597
                break;
1598
1599
            case 'forum_category':
1600
                if (!empty($entity->cat_title)) {
1601
                    return (string) $entity->cat_title;
1602
                }
1603
1604
                break;
1605
1606
            case 'link':
1607
                if (!empty($obj->title)) {
1608
                    return (string) $obj->title;
1609
                }
1610
                if (!empty($obj->url)) {
1611
                    return (string) $obj->url;
1612
                }
1613
1614
                break;
1615
1616
            case 'survey':
1617
                if (!empty($obj->title)) {
1618
                    return trim((string) $obj->title);
1619
                }
1620
1621
                break;
1622
1623
            case 'learnpath':
1624
                if (!empty($obj->name)) {
1625
                    return (string) $obj->name;
1626
                }
1627
1628
                break;
1629
1630
            case 'thematic':
1631
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
1632
                    return (string) $obj->params['title'];
1633
                }
1634
1635
                break;
1636
1637
            case 'quiz':
1638
                if (!empty($entity->title)) {
1639
                    return (string) $entity->title;
1640
                }
1641
1642
                break;
1643
1644
            case 'forum_topic':
1645
                if (!empty($entity->thread_title)) {
1646
                    return (string) $entity->thread_title;
1647
                }
1648
1649
                break;
1650
        }
1651
1652
        return '#'.$fallbackId;
1653
    }
1654
1655
    /**
1656
     * Extract wrapped entity (->obj) or the object itself.
1657
     */
1658
    private function objectEntity(object $resource): object
1659
    {
1660
        if (isset($resource->obj) && \is_object($resource->obj)) {
1661
            return $resource->obj;
1662
        }
1663
1664
        return $resource;
1665
    }
1666
1667
    /**
1668
     * Extra payload per item for UI (optional).
1669
     */
1670
    private function buildExtra(string $type, object $obj): array
1671
    {
1672
        $extra = [];
1673
1674
        $get = static function (object $o, string $k, $default = null) {
1675
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
1676
        };
1677
1678
        switch ($type) {
1679
            case 'document':
1680
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
1681
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
1682
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
1683
1684
                break;
1685
1686
            case 'link':
1687
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
1688
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
1689
1690
                break;
1691
1692
            case 'forum':
1693
                $entity = $this->objectEntity($obj);
1694
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
1695
                $extra['default_view'] = (string) ($entity->default_view ?? '');
1696
1697
                break;
1698
1699
            case 'learnpath':
1700
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
1701
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
1702
                    return [
1703
                        'id' => (int) ($i['id'] ?? 0),
1704
                        'title' => (string) ($i['title'] ?? ''),
1705
                        'type' => (string) ($i['item_type'] ?? ''),
1706
                        'path' => (string) ($i['path'] ?? ''),
1707
                    ];
1708
                }, $obj->items) : [];
1709
1710
                break;
1711
1712
            case 'thematic':
1713
                if (isset($obj->params) && \is_array($obj->params)) {
1714
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
1715
                }
1716
1717
                break;
1718
1719
            case 'quiz':
1720
                $entity = $this->objectEntity($obj);
1721
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
1722
                    ? array_map('intval', $entity->question_ids)
1723
                    : [];
1724
1725
                break;
1726
1727
            case 'survey':
1728
                $entity = $this->objectEntity($obj);
1729
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
1730
                    ? array_map('intval', $entity->question_ids)
1731
                    : [];
1732
1733
                break;
1734
        }
1735
1736
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
1737
    }
1738
1739
    // --------------------------------------------------------------------------------
1740
    // Selection filtering (used by partial restore)
1741
    // --------------------------------------------------------------------------------
1742
1743
    /**
1744
     * Get first existing key from candidates.
1745
     */
1746
    private function firstExistingKey(array $orig, array $candidates): ?string
1747
    {
1748
        foreach ($candidates as $k) {
1749
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
1750
                return $k;
1751
            }
1752
        }
1753
1754
        return null;
1755
    }
1756
1757
    /**
1758
     * Filter legacy Course by UI selections (and pull dependencies).
1759
     *
1760
     * @param array $selected [type => [id => true]]
1761
     */
1762
    private function filterLegacyCourseBySelection(object $course, array $selected): object
1763
    {
1764
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
1765
1766
        if (empty($course->resources) || !\is_array($course->resources)) {
1767
            $this->logDebug('[filterSelection] course has no resources');
1768
1769
            return $course;
1770
        }
1771
1772
        /** @var array<string,mixed> $orig */
1773
        $orig = $course->resources;
1774
1775
        // Keep meta buckets (keys starting with "__") so we don't lose import_source, etc.
1776
        $__metaBuckets = [];
1777
        foreach ($orig as $k => $v) {
1778
            if (\is_string($k) && str_starts_with($k, '__')) {
1779
                $__metaBuckets[$k] = $v;
1780
            }
1781
        }
1782
1783
        $getBucket = static function (array $a, string $key): array {
1784
            return (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
1785
        };
1786
1787
        // Forums flow
1788
        if (!empty($selected) && !empty($selected['forum'])) {
1789
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
1790
            if (!empty($selForums)) {
1791
                $forums = $getBucket($orig, 'forum');
1792
                $catsToKeep = [];
1793
1794
                foreach ($forums as $fid => $f) {
1795
                    if (!isset($selForums[(string) $fid])) {
1796
                        continue;
1797
                    }
1798
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
1799
                    $cid = (int) ($e->forum_category ?? 0);
1800
                    if ($cid > 0) {
1801
                        $catsToKeep[$cid] = true;
1802
                    }
1803
                }
1804
1805
                $threads = $getBucket($orig, 'thread');
1806
                $threadToKeep = [];
1807
                foreach ($threads as $tid => $t) {
1808
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
1809
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
1810
                        $threadToKeep[(int) $tid] = true;
1811
                    }
1812
                }
1813
1814
                $posts = $getBucket($orig, 'post');
1815
                $postToKeep = [];
1816
                foreach ($posts as $pid => $p) {
1817
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
1818
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
1819
                        $postToKeep[(int) $pid] = true;
1820
                    }
1821
                }
1822
1823
                $out = [];
1824
                foreach ($selected as $type => $ids) {
1825
                    if (!\is_array($ids) || empty($ids)) {
1826
                        continue;
1827
                    }
1828
                    $bucket = $getBucket($orig, (string) $type);
1829
                    if (!empty($bucket)) {
1830
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
1831
                        $out[$type] = array_intersect_key($bucket, $idsMap);
1832
                    }
1833
                }
1834
1835
                $forumCat = $getBucket($orig, 'Forum_Category');
1836
                if (!empty($forumCat)) {
1837
                    $out['Forum_Category'] = array_intersect_key(
1838
                        $forumCat,
1839
                        array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
1840
                    );
1841
                }
1842
1843
                $forumBucket = $getBucket($orig, 'forum');
1844
                if (!empty($forumBucket)) {
1845
                    $out['forum'] = array_intersect_key($forumBucket, $selForums);
1846
                }
1847
1848
                $threadBucket = $getBucket($orig, 'thread');
1849
                if (!empty($threadBucket)) {
1850
                    $out['thread'] = array_intersect_key(
1851
                        $threadBucket,
1852
                        array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
1853
                    );
1854
                }
1855
1856
                $postBucket = $getBucket($orig, 'post');
1857
                if (!empty($postBucket)) {
1858
                    $out['post'] = array_intersect_key(
1859
                        $postBucket,
1860
                        array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
1861
                    );
1862
                }
1863
1864
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
1865
                    $out['Forum_Category'] = $forumCat;
1866
                }
1867
1868
                // Preserve meta buckets
1869
                if (!empty($__metaBuckets)) {
1870
                    $out = array_filter($out);
1871
                    $course->resources = $__metaBuckets + $out;
1872
                } else {
1873
                    $course->resources = array_filter($out);
1874
                }
1875
1876
                $this->logDebug('[filterSelection] end', [
1877
                    'kept_types' => array_keys($course->resources),
1878
                    'forum_counts' => [
1879
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
1880
                        'forum' => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
1881
                        'thread' => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
1882
                        'post' => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
1883
                    ],
1884
                ]);
1885
1886
                return $course;
1887
            }
1888
        }
1889
1890
        // Generic + quiz/survey/gradebook flows
1891
        $alias = [
1892
            'tool_intro' => 'Tool introduction',
1893
        ];
1894
1895
        $keep = [];
1896
        foreach ($selected as $type => $ids) {
1897
            if (!\is_array($ids) || empty($ids)) {
1898
                continue;
1899
            }
1900
1901
            $legacyKey = $type;
1902
            if (!isset($orig[$legacyKey]) && isset($alias[$type])) {
1903
                $legacyKey = $alias[$type];
1904
            }
1905
1906
            $bucket = $getBucket($orig, (string) $legacyKey);
1907
            if (!empty($bucket)) {
1908
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
1909
                $keep[$legacyKey] = array_intersect_key($bucket, $idsMap);
1910
            }
1911
        }
1912
1913
        // Gradebook bucket
1914
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
1915
        if ($gbKey && !empty($selected['gradebook'])) {
1916
            $gbBucket = $getBucket($orig, $gbKey);
1917
            if (!empty($gbBucket)) {
1918
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
1919
                $firstItem = reset($gbBucket);
1920
1921
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
1922
                    $keep[$gbKey] = $gbBucket;
1923
                    $this->logDebug('[filterSelection] kept full gradebook bucket', ['key' => $gbKey, 'count' => \count($gbBucket)]);
1924
                } else {
1925
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
1926
                    $this->logDebug('[filterSelection] kept partial gradebook bucket', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
1927
                }
1928
            }
1929
        }
1930
1931
        // Quizzes → questions (+ images)
1932
        $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']);
1933
        if ($quizKey && !empty($keep[$quizKey])) {
1934
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
1935
            if ($questionKey) {
1936
                $qids = [];
1937
                foreach ($keep[$quizKey] as $qid => $qwrap) {
1938
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
1939
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
1940
                        foreach ($q->question_ids as $sid) {
1941
                            $qids[(string) $sid] = true;
1942
                        }
1943
                    }
1944
                }
1945
1946
                if (!empty($qids)) {
1947
                    $questionBucket = $getBucket($orig, $questionKey);
1948
                    $selQ = array_intersect_key($questionBucket, $qids);
1949
                    if (!empty($selQ)) {
1950
                        $keep[$questionKey] = $selQ;
1951
                        $this->logDebug('[filterSelection] pulled question bucket for quizzes', [
1952
                            'quiz_count' => \count($keep[$quizKey]),
1953
                            'question_key' => $questionKey,
1954
                            'questions_kept' => \count($keep[$questionKey]),
1955
                        ]);
1956
1957
                        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
1958
                        if ($docKey) {
1959
                            $docBucket = $getBucket($orig, $docKey);
1960
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
1961
                            if (!empty($imageQuizBucket)) {
1962
                                $needed = [];
1963
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
1964
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
1965
                                    $pic = (string) ($q->picture ?? '');
1966
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
1967
                                        $needed[$pic] = true;
1968
                                    }
1969
                                }
1970
                                if (!empty($needed)) {
1971
                                    $keep[$docKey] = $keep[$docKey] ?? [];
1972
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
1973
                                    $this->logDebug('[filterSelection] included image_quiz docs for questions', [
1974
                                        'count' => \count($keep[$docKey]['image_quiz']),
1975
                                    ]);
1976
                                }
1977
                            }
1978
                        }
1979
                    }
1980
                }
1981
            } else {
1982
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found in backup');
1983
            }
1984
        }
1985
1986
        // Surveys → questions (+ invitations)
1987
        $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']);
1988
        if ($surveyKey && !empty($keep[$surveyKey])) {
1989
            $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
1990
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
1991
1992
            if ($surveyQuestionKey) {
1993
                $neededQids = [];
1994
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
1995
1996
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
1997
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
1998
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
1999
                        foreach ($s->question_ids as $qid) {
2000
                            $neededQids[(string) $qid] = true;
2001
                        }
2002
                    }
2003
                }
2004
2005
                if (empty($neededQids)) {
2006
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2007
                    foreach ($surveyQBucket as $qid => $qWrap) {
2008
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2009
                        $qSurveyId = (string) ($q->survey_id ?? '');
2010
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2011
                            $neededQids[(string) $qid] = true;
2012
                        }
2013
                    }
2014
                }
2015
2016
                if (!empty($neededQids)) {
2017
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2018
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2019
                    $this->logDebug('[filterSelection] pulled question bucket for surveys', [
2020
                        'survey_count' => \count($keep[$surveyKey]),
2021
                        'question_key' => $surveyQuestionKey,
2022
                        'questions_kept' => \count($keep[$surveyQuestionKey]),
2023
                    ]);
2024
                } else {
2025
                    $this->logDebug('[filterSelection] surveys selected but no matching questions found');
2026
                }
2027
            } else {
2028
                $this->logDebug('[filterSelection] surveys selected but no question bucket found in backup');
2029
            }
2030
2031
            if ($surveyInvitationKey) {
2032
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2033
                if (!empty($invBucket)) {
2034
                    $neededInv = [];
2035
                    foreach ($invBucket as $iid => $invWrap) {
2036
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2037
                        $sid = (string) ($inv->survey_id ?? '');
2038
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2039
                            $neededInv[(string) $iid] = true;
2040
                        }
2041
                    }
2042
                    if (!empty($neededInv)) {
2043
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2044
                        $this->logDebug('[filterSelection] included survey invitations', [
2045
                            'invitations_kept' => \count($keep[$surveyInvitationKey]),
2046
                        ]);
2047
                    }
2048
                }
2049
            }
2050
        }
2051
2052
        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2053
        if ($docKey && !empty($keep[$docKey])) {
2054
            $docBucket = $getBucket($orig, $docKey);
2055
2056
            $foldersByRel = [];
2057
            foreach ($docBucket as $fid => $res) {
2058
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2059
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2060
                $isFolder = ('folder' === $ftRaw);
2061
                if (!$isFolder) {
2062
                    $pTest = (string) ($e->path ?? '');
2063
                    if ('' !== $pTest) {
2064
                        $isFolder = ('/' === substr($pTest, -1));
2065
                    }
2066
                }
2067
                if (!$isFolder) {
2068
                    continue;
2069
                }
2070
2071
                $p = (string) ($e->path ?? '');
2072
                if ('' === $p) {
2073
                    continue;
2074
                }
2075
2076
                $frel = '/'.ltrim(substr($p, 8), '/');
2077
                $frel = rtrim($frel, '/').'/';
2078
                if ('//' !== $frel) {
2079
                    $foldersByRel[$frel] = $fid;
2080
                }
2081
            }
2082
2083
            $needFolderIds = [];
2084
            foreach ($keep[$docKey] as $id => $res) {
2085
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2086
2087
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2088
                $isFolder = ('folder' === $ftRaw) || ('/' === substr((string) ($e->path ?? ''), -1));
2089
                if ($isFolder) {
2090
                    continue;
2091
                }
2092
2093
                $p = (string) ($e->path ?? '');
2094
                if ('' === $p) {
2095
                    continue;
2096
                }
2097
2098
                $rel = '/'.ltrim(substr($p, 8), '/');
2099
                $dir = rtrim(\dirname($rel), '/');
2100
                if ('' === $dir) {
2101
                    continue;
2102
                }
2103
2104
                $acc = '';
2105
                foreach (array_filter(explode('/', $dir)) as $seg) {
2106
                    $acc .= '/'.$seg;
2107
                    $accKey = rtrim($acc, '/').'/';
2108
                    if (isset($foldersByRel[$accKey])) {
2109
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2110
                    }
2111
                }
2112
            }
2113
2114
            if (!empty($needFolderIds)) {
2115
                $added = array_intersect_key($docBucket, $needFolderIds);
2116
                $keep[$docKey] += $added;
2117
2118
                $this->logDebug('[filterSelection] added parent folders for selected documents', [
2119
                    'doc_key' => $docKey,
2120
                    'added_folders' => \count($added),
2121
                ]);
2122
            }
2123
        }
2124
2125
        $lnkKey = $this->firstExistingKey(
2126
            $orig,
2127
            ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']
2128
        );
2129
2130
        if ($lnkKey && !empty($keep[$lnkKey])) {
2131
            $catIdsUsed = [];
2132
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2133
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2134
                $cid = (int) ($L->category_id ?? 0);
2135
                if ($cid > 0) {
2136
                    $catIdsUsed[(string) $cid] = true;
2137
                }
2138
            }
2139
2140
            $catKey = $this->firstExistingKey(
2141
                $orig,
2142
                ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']
2143
            );
2144
2145
            if ($catKey && !empty($catIdsUsed)) {
2146
                $catBucket = $getBucket($orig, $catKey);
2147
                if (!empty($catBucket)) {
2148
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
2149
                    $keep[$catKey] = $subset;
2150
                    $keep['link_category'] = $subset;
2151
2152
                    $this->logDebug('[filterSelection] pulled link categories for selected links', [
2153
                        'link_key' => $lnkKey,
2154
                        'category_key' => $catKey,
2155
                        'links_kept' => \count($keep[$lnkKey]),
2156
                        'cats_kept' => \count($subset),
2157
                        'mirrored_to' => 'link_category',
2158
                    ]);
2159
                }
2160
            } else {
2161
                $this->logDebug('[filterSelection] link category bucket not found in backup');
2162
            }
2163
        }
2164
2165
        $keep = array_filter($keep);
2166
        if (!empty($__metaBuckets)) {
2167
            $course->resources = $__metaBuckets + $keep;
2168
        } else {
2169
            $course->resources = $keep;
2170
        }
2171
2172
        $this->logDebug('[filterSelection] non-forum flow end', [
2173
            'kept_types' => array_keys($course->resources),
2174
        ]);
2175
2176
        return $course;
2177
    }
2178
2179
    /**
2180
     * Map UI options (1/2/3) to legacy file policy.
2181
     */
2182
    private function mapSameNameOption(int $opt): int
2183
    {
2184
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
2185
2186
        if (!\defined('FILE_SKIP')) {
2187
            \define('FILE_SKIP', 1);
2188
        }
2189
        if (!\defined('FILE_RENAME')) {
2190
            \define('FILE_RENAME', 2);
2191
        }
2192
        if (!\defined('FILE_OVERWRITE')) {
2193
            \define('FILE_OVERWRITE', 3);
2194
        }
2195
2196
        return match ($opt) {
2197
            1 => FILE_SKIP,
2198
            3 => FILE_OVERWRITE,
2199
            default => FILE_RENAME,
2200
        };
2201
    }
2202
2203
    /**
2204
     * Set debug mode from Request (query/header).
2205
     */
2206
    private function setDebugFromRequest(?Request $req): void
2207
    {
2208
        if (!$req) {
2209
            return;
2210
        }
2211
        // Query param wins
2212
        if ($req->query->has('debug')) {
2213
            $this->debug = $req->query->getBoolean('debug');
2214
2215
            return;
2216
        }
2217
        // Fallback to header
2218
        $hdr = $req->headers->get('X-Debug');
2219
        if (null !== $hdr) {
2220
            $val = trim((string) $hdr);
2221
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
2222
        }
2223
    }
2224
2225
    /**
2226
     * Debug logger with stage + compact JSON payload.
2227
     */
2228
    private function logDebug(string $stage, mixed $payload = null): void
2229
    {
2230
        if (!$this->debug) {
2231
            return;
2232
        }
2233
        $prefix = 'COURSE_DEBUG';
2234
        if (null === $payload) {
2235
            error_log("$prefix: $stage");
2236
2237
            return;
2238
        }
2239
        // Safe/short json
2240
        $json = null;
2241
2242
        try {
2243
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
2244
            if (null !== $json && \strlen($json) > 8000) {
2245
                $json = substr($json, 0, 8000).'…(truncated)';
2246
            }
2247
        } catch (Throwable $e) {
2248
            $json = '[payload_json_error: '.$e->getMessage().']';
2249
        }
2250
        error_log("$prefix: $stage -> $json");
2251
    }
2252
2253
    /**
2254
     * Snapshot of resources bag for quick inspection.
2255
     */
2256
    private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array
2257
    {
2258
        $out = [];
2259
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2260
        $i = 0;
2261
        foreach ($res as $type => $bag) {
2262
            if ($i++ >= $maxTypes) {
2263
                $out['__notice'] = 'types truncated';
2264
2265
                break;
2266
            }
2267
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
2268
            if (\is_array($bag)) {
2269
                $j = 0;
2270
                foreach ($bag as $id => $obj) {
2271
                    if ($j++ >= $maxItemsPerType) {
2272
                        $snap['sample'][] = ['__notice' => 'truncated'];
2273
2274
                        break;
2275
                    }
2276
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
2277
                    $snap['sample'][] = [
2278
                        'id' => (string) $id,
2279
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
2280
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
2281
                    ];
2282
                }
2283
            }
2284
            $out[(string) $type] = $snap;
2285
        }
2286
2287
        return $out;
2288
    }
2289
2290
    /**
2291
     * Snapshot of forum-family counters.
2292
     */
2293
    private function snapshotForumCounts(object $course): array
2294
    {
2295
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
2296
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
2297
2298
        return [
2299
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
2300
            'forum' => $get('forum', 'Forum'),
2301
            'thread' => $get('thread', 'forum_topic'),
2302
            'post' => $get('post', 'forum_post'),
2303
        ];
2304
    }
2305
2306
    /**
2307
     * Builds the selection map [type => [id => true]] from high-level types.
2308
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
2309
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
2310
     *
2311
     * @param object   $course        Legacy Course with already hydrated resources
2312
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
2313
     *
2314
     * @return array<string, array<int|string, bool>>
2315
     */
2316
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
2317
    {
2318
        $selectedTypes = array_map(
2319
            fn ($t) => $this->normalizeTypeKey((string) $t),
2320
            $selectedTypes
2321
        );
2322
2323
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2324
2325
        $coreDeps = [
2326
            'document', 'link', 'quiz', 'work', 'survey',
2327
            'Forum_Category', 'forum', 'thread', 'post',
2328
            'exercise_question', 'survey_question', 'link_category',
2329
        ];
2330
2331
        $presentKeys = array_fill_keys(array_map(
2332
            fn ($k) => $this->normalizeTypeKey((string) $k),
2333
            array_keys($res)
2334
        ), true);
2335
2336
        $out = [];
2337
2338
        $addBucket = function (string $typeKey) use (&$out, $res): void {
2339
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
2340
                return;
2341
            }
2342
            $ids = [];
2343
            foreach ($res[$typeKey] as $id => $_) {
2344
                $ids[(string) $id] = true;
2345
            }
2346
            if ($ids) {
2347
                $out[$typeKey] = $ids;
2348
            }
2349
        };
2350
2351
        foreach ($selectedTypes as $t) {
2352
            $addBucket($t);
2353
2354
            if ('learnpath' === $t) {
2355
                foreach ($coreDeps as $depRaw) {
2356
                    $dep = $this->normalizeTypeKey($depRaw);
2357
                    if (isset($presentKeys[$dep])) {
2358
                        $addBucket($dep);
2359
                    }
2360
                }
2361
            }
2362
        }
2363
2364
        $this->logDebug('[buildSelectionFromTypes] built', [
2365
            'selectedTypes' => $selectedTypes,
2366
            'kept_types' => array_keys($out),
2367
        ]);
2368
2369
        return $out;
2370
    }
2371
2372
    /**
2373
     * Build link tree (Category → Link). Categories are not selectable; links are.
2374
     */
2375
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
2376
    {
2377
        $this->logDebug('[buildLinkTreeForVue] start');
2378
2379
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2380
2381
        // Buckets from backup (accept both legacy casings)
2382
        $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? [];
2383
        $linkRaw = $res['link'] ?? $res['Link'] ?? [];
2384
2385
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
2386
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
2387
            'links' => \is_array($linkRaw) ? \count($linkRaw) : 0,
2388
        ]);
2389
2390
        // Map of categories
2391
        $cats = [];
2392
        foreach ($catRaw as $id => $obj) {
2393
            $id = (int) $id;
2394
            if ($id <= 0 || !\is_object($obj)) {
2395
                continue;
2396
            }
2397
            $e = $this->objectEntity($obj);
2398
            $label = $this->resolveItemLabel('link_category', $e, $id);
2399
2400
            $cats[$id] = [
2401
                'id' => $id,
2402
                'type' => 'link_category',
2403
                'label' => '' !== $label ? $label : ('Category #'.$id),
2404
                'selectable' => false,
2405
                'children' => [],
2406
            ];
2407
        }
2408
2409
        // Virtual "Uncategorized" bucket
2410
        $uncatKey = -9999;
2411
        if (!isset($cats[$uncatKey])) {
2412
            $cats[$uncatKey] = [
2413
                'id' => $uncatKey,
2414
                'type' => 'link_category',
2415
                'label' => 'Uncategorized',
2416
                'selectable' => false,
2417
                'children' => [],
2418
                '_virtual' => true,
2419
            ];
2420
        }
2421
2422
        // Assign links to categories
2423
        foreach ($linkRaw as $id => $obj) {
2424
            $id = (int) $id;
2425
            if ($id <= 0 || !\is_object($obj)) {
2426
                continue;
2427
            }
2428
            $e = $this->objectEntity($obj);
2429
2430
            $cid = (int) ($e->category_id ?? 0);
2431
            if (!isset($cats[$cid])) {
2432
                $cid = $uncatKey;
2433
            }
2434
2435
            $cats[$cid]['children'][] = [
2436
                'id' => $id,
2437
                'type' => 'link',
2438
                'label' => $this->resolveItemLabel('link', $e, $id),
2439
                'extra' => $this->buildExtra('link', $e) ?: new stdClass(),
2440
                'selectable' => true,
2441
            ];
2442
        }
2443
2444
        // Drop empty virtual category and sort
2445
        $catNodes = array_values(array_filter($cats, static function ($c) {
2446
            if (!empty($c['_virtual']) && empty($c['children'])) {
2447
                return false;
2448
            }
2449
2450
            return true;
2451
        }));
2452
2453
        foreach ($catNodes as &$c) {
2454
            if (!empty($c['children'])) {
2455
                usort($c['children'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2456
            }
2457
        }
2458
        unset($c);
2459
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2460
2461
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
2462
2463
        return [
2464
            'type' => 'link',
2465
            'title' => $groupTitle,
2466
            'items' => $catNodes,
2467
        ];
2468
    }
2469
2470
    /**
2471
     * Leaves only the items selected by the UI in $course->resources.
2472
     * Expects $selected with the following form:
2473
     * [
2474
     * "documents" => ["123" => true, "124" => true],
2475
     * "links" => ["7" => true],
2476
     * "quiz" => ["45" => true],
2477
     * ...
2478
     * ].
2479
     */
2480
    private function filterCourseResources(object $course, array $selected): void
0 ignored issues
show
Unused Code introduced by
The method filterCourseResources() 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...
2481
    {
2482
        if (!isset($course->resources) || !\is_array($course->resources)) {
2483
            return;
2484
        }
2485
2486
        $typeMap = [
2487
            'documents' => RESOURCE_DOCUMENT,
2488
            'links' => RESOURCE_LINK,
2489
            'quizzes' => RESOURCE_QUIZ,
2490
            'quiz' => RESOURCE_QUIZ,
2491
            'quiz_questions' => RESOURCE_QUIZQUESTION,
2492
            'surveys' => RESOURCE_SURVEY,
2493
            'survey' => RESOURCE_SURVEY,
2494
            'survey_questions' => RESOURCE_SURVEYQUESTION,
2495
            'announcements' => RESOURCE_ANNOUNCEMENT,
2496
            'events' => RESOURCE_EVENT,
2497
            'course_descriptions' => RESOURCE_COURSEDESCRIPTION,
2498
            'glossary' => RESOURCE_GLOSSARY,
2499
            'wiki' => RESOURCE_WIKI,
2500
            'thematic' => RESOURCE_THEMATIC,
2501
            'attendance' => RESOURCE_ATTENDANCE,
2502
            'works' => RESOURCE_WORK,
2503
            'gradebook' => RESOURCE_GRADEBOOK,
2504
            'learnpaths' => RESOURCE_LEARNPATH,
2505
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
2506
            'tool_intro' => RESOURCE_TOOL_INTRO,
2507
            'forums' => RESOURCE_FORUM,
2508
            'forum' => RESOURCE_FORUM,
2509
            'forum_topic' => RESOURCE_FORUMTOPIC,
2510
            'forum_post' => RESOURCE_FORUMPOST,
2511
        ];
2512
2513
        $allowed = [];
2514
        foreach ($selected as $k => $idsMap) {
2515
            $key = $typeMap[$k] ?? $k;
2516
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
2517
        }
2518
2519
        foreach ($course->resources as $rtype => $bucket) {
2520
            if (!isset($allowed[$rtype])) {
2521
                continue;
2522
            }
2523
            $keep = $allowed[$rtype];
2524
            $filtered = [];
2525
            foreach ((array) $bucket as $id => $obj) {
2526
                $iid = (int) ($obj->source_id ?? $id);
2527
                if (isset($keep[$iid])) {
2528
                    $filtered[$id] = $obj;
2529
                }
2530
            }
2531
            $course->resources[$rtype] = $filtered;
2532
        }
2533
    }
2534
2535
    /**
2536
     * Returns the absolute path of the backupId in the backup directory.
2537
     */
2538
    private function resolveBackupPath(string $backupId): string
2539
    {
2540
        $backupDir = rtrim(CourseArchiver::getBackupDir(), '/');
2541
2542
        return $backupDir.'/'.$backupId;
2543
    }
2544
2545
    /**
2546
     * Heuristic: Does it look like a Moodle package?
2547
     * - If the extension (.mbz, .tgz, .gz) is used, we treat it as Moodle.
2548
     * - If it's a .zip file but CourseArchiver fails or contains "moodle_backup.xml," it's also Moodle.
2549
     */
2550
    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...
2551
    {
2552
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2553
        if (\in_array($ext, ['mbz', 'tgz', 'gz'], true)) {
2554
            return true;
2555
        }
2556
        if ('zip' === $ext && $priorError) {
2557
            return true;
2558
        }
2559
2560
        return false;
2561
    }
2562
2563
    /**
2564
     * Load a legacy course from a Chamilo or Moodle backup, testing both readers.
2565
     * - First try CourseArchiver (Chamilo).
2566
     * - If that fails, retry MoodleImport.
2567
     */
2568
    private function loadLegacyCourseForAnyBackup(string $backupId): object
2569
    {
2570
        $path = $this->resolveBackupPath($backupId);
2571
        $this->logDebug('[loadLegacyCourseForAnyBackup] try Chamilo first', ['path' => $path]);
2572
2573
        // Chamilo ZIP (course_info.dat)
2574
        try {
2575
            $course = CourseArchiver::readCourse($backupId, false);
2576
            if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
2577
                throw new RuntimeException('Invalid Chamilo backup structure (empty resources)');
2578
            }
2579
2580
            return $course;
2581
        } catch (Throwable $e) {
2582
            $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo reader failed, will try Moodle', ['err' => $e->getMessage()]);
2583
            $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2584
            if (!\in_array($ext, ['mbz', 'zip', 'gz', 'tgz'], true)) {
2585
                throw $e;
2586
            }
2587
        }
2588
2589
        // Moodle (.mbz/.zip/.tgz)
2590
        $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
2591
        $importer = new MoodleImport(debug: $this->debug);
2592
        $course = $importer->buildLegacyCourseFromMoodleArchive($path);
2593
2594
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
2595
            throw new RuntimeException('Moodle backup contains no importable resources');
2596
        }
2597
2598
        return $course;
2599
    }
2600
2601
    private function normalizeBucketsForRestorer(object $course): void
2602
    {
2603
        if (!isset($course->resources) || !\is_array($course->resources)) {
2604
            return;
2605
        }
2606
2607
        $map = [
2608
            'link' => RESOURCE_LINK,
2609
            'link_category' => RESOURCE_LINKCATEGORY,
2610
            'forum' => RESOURCE_FORUM,
2611
            'forum_category' => RESOURCE_FORUMCATEGORY,
2612
            'forum_topic' => RESOURCE_FORUMTOPIC,
2613
            'forum_post' => RESOURCE_FORUMPOST,
2614
            'thread' => RESOURCE_FORUMTOPIC,
2615
            'post' => RESOURCE_FORUMPOST,
2616
            'document' => RESOURCE_DOCUMENT,
2617
            'quiz' => RESOURCE_QUIZ,
2618
            'exercise_question' => RESOURCE_QUIZQUESTION,
2619
            'survey' => RESOURCE_SURVEY,
2620
            'survey_question' => RESOURCE_SURVEYQUESTION,
2621
            'tool_intro' => RESOURCE_TOOL_INTRO,
2622
        ];
2623
2624
        $res = $course->resources;
2625
        foreach ($map as $from => $to) {
2626
            if (isset($res[$from]) && \is_array($res[$from])) {
2627
                if (!isset($res[$to])) {
2628
                    $res[$to] = $res[$from];
2629
                }
2630
                unset($res[$from]);
2631
            }
2632
        }
2633
2634
        $course->resources = $res;
2635
    }
2636
2637
    /**
2638
     * Read import_source without depending on filtered resources.
2639
     * Falls back to $course->info['__import_source'] if needed.
2640
     */
2641
    private function getImportSource(object $course): string
2642
    {
2643
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
2644
        if ('' !== $src) {
2645
            return $src;
2646
        }
2647
2648
        // Fallbacks (defensive)
2649
        return strtolower((string) ($course->info['__import_source'] ?? ''));
2650
    }
2651
}
2652