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

CourseMaintenanceController::inferToolsFromSelection()   F

Complexity

Conditions 25
Paths 1024

Size

Total Lines 55
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 25
eloc 26
nc 1024
nop 1
dl 0
loc 55
rs 0
c 0
b 0
f 0

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 __PHP_Incomplete_Class;
10
use Chamilo\CoreBundle\Repository\Node\UserRepository;
11
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Capabilities;
12
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Export;
13
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Imscc13Import;
14
use Chamilo\CourseBundle\Component\CourseCopy\Course;
15
use Chamilo\CourseBundle\Component\CourseCopy\CourseArchiver;
16
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
17
use Chamilo\CourseBundle\Component\CourseCopy\CourseRecycler;
18
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
19
use Chamilo\CourseBundle\Component\CourseCopy\CourseSelectForm;
20
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleExport;
21
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleImport;
22
use CourseManager;
23
use Doctrine\ORM\EntityManagerInterface;
24
use RuntimeException;
25
use stdClass;
26
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
27
use Symfony\Component\HttpFoundation\{BinaryFileResponse, JsonResponse, Request, ResponseHeaderBag};
28
use Symfony\Component\Routing\Attribute\Route;
29
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
30
use Symfony\Component\Security\Http\Attribute\IsGranted;
31
use Throwable;
32
use UnserializeApi;
33
use ZipArchive;
34
35
use const ARRAY_FILTER_USE_BOTH;
36
use const DIRECTORY_SEPARATOR;
37
use const FILTER_VALIDATE_BOOL;
38
use const JSON_PARTIAL_OUTPUT_ON_ERROR;
39
use const JSON_UNESCAPED_SLASHES;
40
use const JSON_UNESCAPED_UNICODE;
41
use const PATHINFO_EXTENSION;
42
43
#[IsGranted('ROLE_TEACHER')]
44
#[Route('/course_maintenance/{node}', name: 'cm_', requirements: ['node' => '\d+'])]
45
class CourseMaintenanceController extends AbstractController
46
{
47
    /**
48
     * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1
49
     */
50
    private bool $debug = true;
51
52
    #[Route('/import/options', name: 'import_options', methods: ['GET'])]
53
    public function importOptions(int $node, Request $req): JsonResponse
54
    {
55
        $this->setDebugFromRequest($req);
56
        $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]);
57
58
        return $this->json([
59
            'sources' => ['local', 'server'],
60
            'importOptions' => ['full_backup', 'select_items'],
61
            'sameName' => ['skip', 'rename', 'overwrite'],
62
            'defaults' => [
63
                'importOption' => 'full_backup',
64
                'sameName' => 'rename',
65
                'sameFileNameOption' => 2,
66
            ],
67
        ]);
68
    }
69
70
    #[Route('/import/upload', name: 'import_upload', methods: ['POST'])]
71
    public function importUpload(int $node, Request $req): JsonResponse
72
    {
73
        $this->setDebugFromRequest($req);
74
75
        $file = $req->files->get('file');
76
        if (!$file || !$file->isValid()) {
77
            return $this->json(['error' => 'Invalid upload'], 400);
78
        }
79
80
        $maxBytes = 1024 * 1024 * 512;
81
        if ($file->getSize() > $maxBytes) {
82
            return $this->json(['error' => 'File too large'], 413);
83
        }
84
85
        $allowed = ['zip', 'mbz', 'gz', 'tgz'];
86
        $ext = strtolower($file->guessExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
87
        if (!\in_array($ext, $allowed, true)) {
88
            return $this->json(['error' => 'Unsupported file type'], 415);
89
        }
90
91
        $this->logDebug('[importUpload] received', [
92
            'original_name' => $file->getClientOriginalName(),
93
            'size' => $file->getSize(),
94
            'mime' => $file->getClientMimeType(),
95
        ]);
96
97
        $backupId = CourseArchiver::importUploadedFile($file->getRealPath());
98
        if (false === $backupId) {
99
            $this->logDebug('[importUpload] archive dir not writable');
100
101
            return $this->json(['error' => 'Archive directory is not writable'], 500);
102
        }
103
104
        $this->logDebug('[importUpload] stored', ['backupId' => $backupId]);
105
106
        return $this->json([
107
            'backupId' => $backupId,
108
            'filename' => $file->getClientOriginalName(),
109
        ]);
110
    }
111
112
    #[Route('/import/server', name: 'import_server_pick', methods: ['POST'])]
113
    public function importServerPick(int $node, Request $req): JsonResponse
114
    {
115
        $this->setDebugFromRequest($req);
116
        $payload = json_decode($req->getContent() ?: '{}', true);
117
118
        $filename = basename((string) ($payload['filename'] ?? ''));
119
        if ('' === $filename || preg_match('/[\/\\\]/', $filename)) {
120
            return $this->json(['error' => 'Invalid filename'], 400);
121
        }
122
123
        $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename;
124
        $realBase = realpath(CourseArchiver::getBackupDir());
125
        $realPath = realpath($path);
126
        if (!$realBase || !$realPath || 0 !== strncmp($realBase, $realPath, \strlen($realBase)) || !is_file($realPath)) {
127
            $this->logDebug('[importServerPick] file not found or outside base', ['path' => $path]);
128
129
            return $this->json(['error' => 'File not found'], 404);
130
        }
131
132
        $this->logDebug('[importServerPick] ok', ['backupId' => $filename]);
133
134
        return $this->json(['backupId' => $filename, 'filename' => $filename]);
135
    }
136
137
    #[Route(
138
        '/import/{backupId}/resources',
139
        name: 'import_resources',
140
        requirements: ['backupId' => '.+'],
141
        methods: ['GET']
142
    )]
143
    public function importResources(int $node, string $backupId, Request $req): JsonResponse
144
    {
145
        $this->setDebugFromRequest($req);
146
        $mode = strtolower((string) $req->query->get('mode', 'auto')); // 'auto' | 'dat' | 'moodle'
147
148
        $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode);
149
150
        $this->logDebug('[importResources] course loaded', [
151
            'has_resources' => \is_array($course->resources ?? null),
152
            'keys' => array_keys((array) ($course->resources ?? [])),
153
        ]);
154
155
        $tree = $this->buildResourceTreeForVue($course);
156
157
        $warnings = [];
158
        if (empty($tree)) {
159
            $warnings[] = 'Backup has no selectable resources.';
160
        }
161
162
        return $this->json([
163
            'tree' => $tree,
164
            'warnings' => $warnings,
165
            'meta' => ['import_source' => $course->resources['__meta']['import_source'] ?? null],
166
        ]);
167
    }
168
169
    #[Route(
170
        '/import/{backupId}/restore',
171
        name: 'import_restore',
172
        requirements: ['backupId' => '.+'],
173
        methods: ['POST']
174
    )]
175
    public function importRestore(
176
        int $node,
177
        string $backupId,
178
        Request $req,
179
        EntityManagerInterface $em
180
    ): JsonResponse {
181
        $this->setDebugFromRequest($req);
182
183
        error_log('COURSE_DEBUG: [importRestore] begin -> ' . json_encode([
184
                'node'     => $node,
185
                'backupId' => $backupId,
186
            ], JSON_UNESCAPED_SLASHES));
187
188
        try {
189
            // Disable profiler & SQL logger for performance
190
            if ($this->container->has('profiler')) {
191
                $profiler = $this->container->get('profiler');
192
                if ($profiler instanceof \Symfony\Component\HttpKernel\Profiler\Profiler) {
193
                    $profiler->disable();
194
                }
195
            }
196
            if ($this->container->has('doctrine')) {
197
                $emDoctrine = $this->container->get('doctrine')->getManager();
198
                if ($emDoctrine && $emDoctrine->getConnection()) {
199
                    $emDoctrine->getConnection()->getConfiguration()->setSQLLogger(null);
200
                }
201
            }
202
203
            // Parse payload
204
            $payload = json_decode($req->getContent() ?: '{}', true) ?: [];
205
            $mode               = strtolower((string) ($payload['mode'] ?? 'auto'));         // 'auto' | 'dat' | 'moodle'
206
            $importOption       = (string)  ($payload['importOption'] ?? 'full_backup');      // 'full_backup' | 'select_items'
207
            $sameFileNameOption = (int)     ($payload['sameFileNameOption'] ?? 2);            // 0 skip | 1 overwrite | 2 rename
208
            $selectedResources  = (array)   ($payload['resources'] ?? []);                    // map type -> [ids]
209
            $selectedTypes      = array_map('strval', (array) ($payload['selectedTypes'] ?? []));
210
211
            error_log('COURSE_DEBUG: [importRestore] input -> ' . json_encode([
212
                    'mode'               => $mode,
213
                    'importOption'       => $importOption,
214
                    'sameFileNameOption' => $sameFileNameOption,
215
                    'selectedTypes.count'=> count($selectedTypes),
216
                    'hasResourcesMap'    => !empty($selectedResources),
217
                ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
218
219
            // Load snapshot (keep same source mode as GET /resources)
220
            $course = $this->loadLegacyCourseForAnyBackup($backupId, $mode === 'dat' ? 'chamilo' : $mode);
221
            if (!\is_object($course) || !\is_array($course->resources ?? null)) {
222
                return $this->json(['error' => 'Backup has no resources'], 400);
223
            }
224
225
            // Quick counts for logging
226
            $counts = [];
227
            foreach ((array) $course->resources as $k => $bag) {
228
                if ($k === '__meta') { continue; }
229
                $counts[$k] = \is_array($bag) ? \count($bag) : 0;
230
            }
231
            error_log('COURSE_DEBUG: [importRestore] snapshot.counts -> ' . json_encode($counts, JSON_UNESCAPED_SLASHES));
232
233
            // Detect source (Moodle vs non-Moodle)
234
            $importSource = strtolower((string) ($course->resources['__meta']['import_source'] ?? $mode));
235
            $isMoodle     = ($importSource === 'moodle');
236
            error_log('COURSE_DEBUG: [importRestore] detected import source -> ' . json_encode([
237
                    'import_source' => $importSource,
238
                    'isMoodle'      => $isMoodle,
239
                ], JSON_UNESCAPED_SLASHES));
240
241
            // Build requested buckets list
242
            if ($importOption === 'select_items') {
243
                if (!empty($selectedResources)) {
244
                    $requested = array_keys(array_filter(
245
                        $selectedResources,
246
                        static fn ($ids) => \is_array($ids) && !empty($ids)
247
                    ));
248
                } elseif (!empty($selectedTypes)) {
249
                    $requested = $selectedTypes;
250
                } else {
251
            // Load with same mode to avoid switching source on POST
252
            $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode);
253
            if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
254
                return $this->json(['error' => 'Backup has no resources'], 400);
255
            }
256
257
            $resourcesAll = $course->resources;
258
            $this->logDebug('[importRestore] BEFORE filter keys', array_keys($resourcesAll));
259
260
            // Always hydrate LP dependencies (even in full_backup).
261
            $this->hydrateLpDependenciesFromSnapshot($course, $resourcesAll);
262
            $this->logDebug('[importRestore] AFTER hydrate keys', array_keys((array) $course->resources));
263
264
            // Detect source BEFORE any filtering (meta may be dropped by filters)
265
            $importSource = $this->getImportSource($course);
266
            $isMoodle = ('moodle' === $importSource);
267
            $this->logDebug('[importRestore] detected import source', ['import_source' => $importSource, 'isMoodle' => $isMoodle]);
268
269
            if ('select_items' === $importOption) {
270
                if (empty($selectedResources) && !empty($selectedTypes)) {
271
                    $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes);
272
                }
273
274
                $hasAny = false;
275
                foreach ($selectedResources as $ids) {
276
                    if (\is_array($ids) && !empty($ids)) {
277
                        $hasAny = true;
278
279
                        break;
280
                    }
281
                }
282
                if (!$hasAny) {
283
                    return $this->json(['error' => 'No resources selected'], 400);
284
                }
285
            } else {
286
                // full_backup => take all snapshot keys (except __meta)
287
                $requested = array_keys(array_filter(
288
                    (array) $course->resources,
289
                    static fn ($k) => $k !== '__meta',
290
                    ARRAY_FILTER_USE_KEY
291
                ));
292
            }
293
            // Normalize for case
294
            $requested = array_values(array_unique(array_map(static fn ($k) => strtolower((string) $k), $requested)));
295
            error_log('COURSE_DEBUG: [importRestore] requested -> ' . json_encode($requested, JSON_UNESCAPED_SLASHES));
296
297
            // Non-Moodle path (use CourseRestorer directly)
298
            if (!$isMoodle) {
299
                error_log('COURSE_DEBUG: [importRestore] non-Moodle path -> CourseRestorer');
300
301
                if ($importOption === 'select_items') {
302
                    $filtered = clone $course;
303
                    $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []];
304
                    foreach ($requested as $rk) {
305
                        if (isset($course->resources[$rk])) {
306
                            $filtered->resources[$rk] = $course->resources[$rk];
307
                        }
308
                    }
309
                    $course = $filtered;
310
                }
311
312
                $restorer = new CourseRestorer($course);
313
                // Restorer understands 0/1/2 for (skip/overwrite/rename)
314
                $restorer->set_file_option($sameFileNameOption);
315
                if (method_exists($restorer, 'setDebug')) {
316
                    $restorer->setDebug($this->debug ?? false);
317
                }
318
319
                $t0 = microtime(true);
320
                $restorer->restore();
321
                $ms = (int) round((microtime(true) - $t0) * 1000);
322
323
                CourseArchiver::cleanBackupDir();
324
325
                $dstId = (int) ($restorer->destination_course_info['real_id'] ?? 0);
326
                error_log('COURSE_DEBUG: [importRestore] non-Moodle DONE ms=' . $ms . ' dstId=' . $dstId);
327
328
                return $this->json([
329
                    'ok'          => true,
330
                    'message'     => 'Import finished',
331
                    'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', $dstId ?: (int) (api_get_course_info()['real_id'] ?? 0)),
332
                ]);
333
            }
334
335
            // Moodle path (documents via MoodleImport, rest via CourseRestorer)
336
            $cacheDir   = (string) $this->getParameter('kernel.cache_dir');
337
            $backupPath = rtrim($cacheDir, '/').'/course_backups/'.$backupId;
338
339
            $stats = $this->restoreMoodle(
340
                $backupPath,
341
                $course,
342
                $requested,
343
                $sameFileNameOption,
344
                $em
345
            );
346
347
            return $this->json([
348
                'ok'          => true,
349
                'message'     => 'Moodle import finished',
350
                'stats'       => $stats,
351
                'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', (int) (api_get_course_info()['real_id'] ?? 0)),
352
            ]);
353
354
        } catch (\Throwable $e) {
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_CATCH on line 354 at column 10
Loading history...
355
            error_log('COURSE_DEBUG: [importRestore] ERROR -> ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
356
            return $this->json([
357
                'error'   => 'Restore failed: ' . $e->getMessage(),
358
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
359
            ], 500);
360
        } finally {
361
            // Defensive cleanup
362
            try { CourseArchiver::cleanBackupDir(); } catch (\Throwable) {}
363
        }
364
    }
365
366
    /**
367
     * Single helper for Moodle branch:
368
     * - Restore documents from MBZ (needs ZIP path and MoodleImport).
369
     * - Restore remaining buckets using CourseRestorer over the filtered snapshot.
370
     */
371
    private function restoreMoodle(
372
        string $backupPath,
373
        object $course,
374
        array $requested,
375
        int $sameFileNameOption,
376
        EntityManagerInterface $em
377
    ): array {
378
        $stats = [];
379
        $ci  = api_get_course_info();
380
        $cid = (int) ($ci['real_id'] ?? 0);
381
        $sid = 0;
382
383
        // 1) Documents (if requested)
384
        $wantsDocs = \in_array('document', $requested, true) || \in_array('documents', $requested, true);
385
        if ($wantsDocs) {
386
            $tag = substr(dechex(random_int(0, 0xFFFFFF)), 0, 6);
387
            error_log(sprintf('MBZ[%s] RESTORE_DOCS: begin path=%s', $tag, $backupPath));
388
389
            $importer = new MoodleImport(debug: $this->debug ?? false);
390
391
            $courseForDocs = clone $course;
392
            $courseForDocs->resources = [
393
                '__meta'   => $course->resources['__meta'] ?? [],
394
                'document' => $course->resources['document'] ?? [],
395
            ];
396
397
            $t0 = microtime(true);
398
            if (method_exists($importer, 'restoreDocuments')) {
399
                $res = $importer->restoreDocuments(
400
                    $backupPath,
401
                    $em,
402
                    $cid,
403
                    $sid,
404
                    (int) $sameFileNameOption,
405
                    $courseForDocs
406
                );
407
                $stats['documents'] = $res ?? ['imported' => 0];
408
            } else {
409
                error_log('MBZ['.$tag.'] RESTORE_DOCS: restoreDocuments() not found on importer');
410
                $stats['documents'] = ['imported' => 0, 'notes' => ['restoreDocuments() missing']];
411
            }
412
            $ms = (int) round((microtime(true) - $t0) * 1000);
413
            error_log(sprintf('MBZ[%s] RESTORE_DOCS: end ms=%d', $tag, $ms));
414
        }
415
416
        // 2) Remaining buckets via CourseRestorer (links, forums, announcements, attendance, etc.)
417
        $restRequested = array_values(array_filter($requested, static function ($k) {
418
            $k = strtolower((string) $k);
419
            return $k !== 'document' && $k !== 'documents';
420
        }));
421
        if (empty($restRequested)) {
422
            return $stats;
423
        }
424
425
        $filtered = clone $course;
426
        $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []];
427
        foreach ($restRequested as $rk) {
428
            if (isset($course->resources[$rk])) {
429
                $filtered->resources[$rk] = $course->resources[$rk];
430
            }
431
        }
432
        // Simple dependency example: include Link_Category when links are requested
433
        if (\in_array('link', $restRequested, true) || \in_array('links', $restRequested, true)) {
434
            if (isset($course->resources['Link_Category'])) {
435
                $filtered->resources['Link_Category'] = $course->resources['Link_Category'];
436
            }
437
        }
438
439
        error_log('COURSE_DEBUG: [restoreMoodle] restorer.keys -> ' . json_encode(array_keys((array) $filtered->resources), JSON_UNESCAPED_SLASHES));
440
441
        $restorer = new CourseRestorer($filtered);
442
        $restorer->set_file_option($sameFileNameOption);
443
        if (method_exists($restorer, 'setDebug')) {
444
            $restorer->setDebug($this->debug ?? false);
445
        }
446
447
        $t1 = microtime(true);
448
        $restorer->restore();
449
        $ms = (int) round((microtime(true) - $t1) * 1000);
450
        error_log('COURSE_DEBUG: [restoreMoodle] restorer DONE ms=' . $ms);
451
452
        $stats['restored_tools'] = array_values(array_filter(
453
            array_keys((array) $filtered->resources),
454
            static fn($k) => $k !== '__meta'
455
        ));
456
457
        return $stats;
458
    }
459
460
    #[Route('/copy/options', name: 'copy_options', methods: ['GET'])]
461
    public function copyOptions(int $node, Request $req): JsonResponse
462
    {
463
        $this->setDebugFromRequest($req);
464
465
        $current = api_get_course_info();
466
        $courseList = CourseManager::getCoursesFollowedByUser(api_get_user_id());
467
468
        $courses = [];
469
        foreach ($courseList as $c) {
470
            if ((int) $c['real_id'] === (int) $current['real_id']) {
471
                continue;
472
            }
473
            $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']];
474
        }
475
476
        return $this->json([
477
            'courses' => $courses,
478
            'defaults' => [
479
                'copyOption' => 'full_copy',
480
                'includeUsers' => false,
481
                'resetDates' => true,
482
                'sameFileNameOption' => 2,
483
            ],
484
        ]);
485
    }
486
487
    #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])]
488
    public function copyResources(int $node, Request $req): JsonResponse
489
    {
490
        $this->setDebugFromRequest($req);
491
        $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', ''));
492
        if ('' === $sourceCourseCode) {
493
            return $this->json(['error' => 'Missing sourceCourseId'], 400);
494
        }
495
496
        $cb = new CourseBuilder();
497
        $cb->set_tools_to_build([
498
            'documents',
499
            'forums',
500
            'tool_intro',
501
            'links',
502
            'quizzes',
503
            'quiz_questions',
504
            'assets',
505
            'surveys',
506
            'survey_questions',
507
            'announcement',
508
            'events',
509
            'course_descriptions',
510
            'glossary',
511
            'wiki',
512
            'thematic',
513
            'attendance',
514
            'works',
515
            'gradebook',
516
            'learnpath_category',
517
            'learnpaths',
518
        ]);
519
520
        $course = $cb->build(0, $sourceCourseCode);
521
522
        $tree = $this->buildResourceTreeForVue($course);
523
524
        $warnings = [];
525
        if (empty($tree)) {
526
            $warnings[] = 'Source course has no resources.';
527
        }
528
529
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
530
    }
531
532
    #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])]
533
    public function copyExecute(int $node, Request $req): JsonResponse
534
    {
535
        $this->setDebugFromRequest($req);
536
537
        try {
538
            $payload = json_decode($req->getContent() ?: '{}', true);
539
540
            $sourceCourseId = (string) ($payload['sourceCourseId'] ?? '');
541
            $copyOption = (string) ($payload['copyOption'] ?? 'full_copy');
542
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
543
            $selectedResourcesMap = (array) ($payload['resources'] ?? []);
544
545
            if ('' === $sourceCourseId) {
546
                return $this->json(['error' => 'Missing sourceCourseId'], 400);
547
            }
548
549
            $cb = new CourseBuilder('partial');
550
            $cb->set_tools_to_build([
551
                'documents',
552
                'forums',
553
                'tool_intro',
554
                'links',
555
                'quizzes',
556
                'quiz_questions',
557
                'assets',
558
                'surveys',
559
                'survey_questions',
560
                'announcement',
561
                'events',
562
                'course_descriptions',
563
                'glossary',
564
                'wiki',
565
                'thematic',
566
                'attendance',
567
                'works',
568
                'gradebook',
569
                'learnpath_category',
570
                'learnpaths',
571
            ]);
572
            $legacyCourse = $cb->build(0, $sourceCourseId);
573
574
            if ('select_items' === $copyOption) {
575
                $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap);
576
577
                if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) {
578
                    return $this->json(['error' => 'Selection produced no resources to copy'], 400);
579
                }
580
            }
581
582
            error_log('$legacyCourse :::: '.print_r($legacyCourse, true));
583
584
            $restorer = new CourseRestorer($legacyCourse);
585
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
586
            if (method_exists($restorer, 'setDebug')) {
587
                $restorer->setDebug($this->debug);
588
            }
589
            $restorer->restore();
590
591
            $dest = api_get_course_info();
592
            $redirectUrl = \sprintf('/course/%d/home', (int) $dest['real_id']);
593
594
            return $this->json([
595
                'ok' => true,
596
                'message' => 'Copy finished',
597
                'redirectUrl' => $redirectUrl,
598
            ]);
599
        } catch (Throwable $e) {
600
            return $this->json([
601
                'error' => 'Copy failed: '.$e->getMessage(),
602
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
603
            ], 500);
604
        }
605
    }
606
607
    #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])]
608
    public function recycleOptions(int $node, Request $req): JsonResponse
609
    {
610
        $this->setDebugFromRequest($req);
611
612
        // current course only
613
        $defaults = [
614
            'recycleOption' => 'select_items', // 'full_recycle' | 'select_items'
615
            'confirmNeeded' => true,           // show code-confirm input when full
616
        ];
617
618
        return $this->json(['defaults' => $defaults]);
619
    }
620
621
    #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])]
622
    public function recycleResources(int $node, Request $req): JsonResponse
623
    {
624
        $this->setDebugFromRequest($req);
625
626
        // Build legacy Course from CURRENT course (not “source”)
627
        $cb = new CourseBuilder();
628
        $cb->set_tools_to_build([
629
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
630
            'survey_questions', 'announcement', 'events', 'course_descriptions', 'glossary', 'wiki',
631
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
632
        ]);
633
        $course = $cb->build(0, api_get_course_id());
634
635
        $tree = $this->buildResourceTreeForVue($course);
636
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
637
638
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
639
    }
640
641
    #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])]
642
    public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse
643
    {
644
        try {
645
            $p = json_decode($req->getContent() ?: '{}', true);
646
            $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items'
647
            $resourcesMap = (array) ($p['resources'] ?? []);
648
            $confirmCode = (string) ($p['confirm'] ?? '');
649
650
            $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items';
651
652
            if ('full_backup' === $type) {
653
                if ($confirmCode !== api_get_course_id()) {
654
                    return $this->json(['error' => 'Course code confirmation mismatch'], 400);
655
                }
656
            } else {
657
                if (empty($resourcesMap)) {
658
                    return $this->json(['error' => 'No resources selected'], 400);
659
                }
660
            }
661
662
            $courseCode = api_get_course_id();
663
            $courseInfo = api_get_course_info($courseCode);
664
            $courseId = (int) ($courseInfo['real_id'] ?? 0);
665
            if ($courseId <= 0) {
666
                return $this->json(['error' => 'Invalid course id'], 400);
667
            }
668
669
            $recycler = new CourseRecycler(
670
                $em,
671
                $courseCode,
672
                $courseId
673
            );
674
675
            $recycler->recycle($type, $resourcesMap);
676
677
            return $this->json([
678
                'ok' => true,
679
                'message' => 'Recycle finished',
680
            ]);
681
        } catch (Throwable $e) {
682
            return $this->json([
683
                'error' => 'Recycle failed: '.$e->getMessage(),
684
            ], 500);
685
        }
686
    }
687
688
    #[Route('/delete', name: 'delete', methods: ['POST'])]
689
    public function deleteCourse(int $node, Request $req): JsonResponse
690
    {
691
        // Basic permission gate (adjust roles to your policy if needed)
692
        if (
693
            !$this->isGranted('ROLE_ADMIN')
694
            && !$this->isGranted('ROLE_TEACHER')
695
            && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
696
        ) {
697
            return $this->json(['error' => 'You are not allowed to delete this course'], 403);
698
        }
699
700
        try {
701
            $payload = json_decode($req->getContent() ?: '{}', true);
702
            $confirm = trim((string) ($payload['confirm'] ?? ''));
703
704
            if ('' === $confirm) {
705
                return $this->json(['error' => 'Missing confirmation value'], 400);
706
            }
707
708
            // Optional flag: also delete orphan documents that belong only to this course
709
            // Accepts 1/0, true/false, "1"/"0"
710
            $deleteDocsRaw = $payload['delete_docs'] ?? 0;
711
            $deleteDocs = filter_var($deleteDocsRaw, FILTER_VALIDATE_BOOL);
712
713
            // Current course
714
            $courseInfo = api_get_course_info();
715
            if (empty($courseInfo)) {
716
                return $this->json(['error' => 'Unable to resolve current course'], 400);
717
            }
718
719
            $officialCode = (string) ($courseInfo['official_code'] ?? '');
720
            $runtimeCode = (string) api_get_course_id(); // often equals official code
721
            $sysCode = (string) ($courseInfo['sysCode'] ?? ''); // used by legacy delete
722
723
            if ('' === $sysCode) {
724
                return $this->json(['error' => 'Invalid course system code'], 400);
725
            }
726
727
            // Accept either official_code or api_get_course_id() as confirmation
728
            $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm);
729
            if (!$matches) {
730
                return $this->json(['error' => 'Course code confirmation mismatch'], 400);
731
            }
732
733
            // Legacy delete (removes course data + unregisters members in this course)
734
            // Now with optional orphan-docs deletion flag.
735
            CourseManager::delete_course($sysCode, $deleteDocs);
736
737
            // Best-effort cleanup of legacy course session flags
738
            try {
739
                $ses = $req->getSession();
740
                $ses?->remove('_cid');
741
                $ses?->remove('_real_cid');
742
            } catch (Throwable $e) {
743
                // swallow — not critical
744
            }
745
746
            // Decide where to send the user afterwards
747
            $redirectUrl = '/index.php';
748
749
            return $this->json([
750
                'ok' => true,
751
                'message' => 'Course deleted successfully',
752
                'redirectUrl' => $redirectUrl,
753
            ]);
754
        } catch (Throwable $e) {
755
            return $this->json([
756
                'error' => 'Failed to delete course: '.$e->getMessage(),
757
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
758
            ], 500);
759
        }
760
    }
761
762
    #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])]
763
    public function moodleExportOptions(int $node, Request $req, UserRepository $users): JsonResponse
764
    {
765
        $defaults = [
766
            'moodleVersion' => '4',
767
            'scope' => 'full',
768
            'admin' => $users->getDefaultAdminForExport(),
769
        ];
770
771
        $tools = [
772
            ['value' => 'documents', 'label' => 'Documents (files & root HTML pages)'],
773
            ['value' => 'links', 'label' => 'Links (URL)'],
774
            ['value' => 'forums', 'label' => 'Forums'],
775
            ['value' => 'quizzes', 'label' => 'Quizzes', 'implies' => ['quiz_questions']],
776
            ['value' => 'surveys', 'label' => 'Surveys', 'implies' => ['survey_questions']],
777
            ['value' => 'works', 'label' => 'Tasks'],
778
            ['value' => 'glossary', 'label' => 'Glossary'],
779
            ['value' => 'learnpaths', 'label' => 'Paths learning'],
780
            ['value' => 'tool_intro', 'label' => 'Course Introduction'],
781
            ['value' => 'course_description', 'label' => 'Course descriptions'],
782
            ['value' => 'attendance', 'label' => 'Attendance'],
783
            ['value' => 'announcement', 'label' => 'Announcements'],
784
            ['value' => 'events', 'label' => 'Calendar events'],
785
            ['value' => 'wiki', 'label' => 'Wiki'],
786
            ['value' => 'thematic', 'label' => 'Thematic'],
787
            ['value' => 'gradebook', 'label' => 'Gradebook'],
788
        ];
789
790
        $defaults['tools'] = array_column($tools, 'value');
791
792
        return $this->json([
793
            'versions' => [
794
                ['value' => '3', 'label' => 'Moodle 3.x'],
795
                ['value' => '4', 'label' => 'Moodle 4.x'],
796
            ],
797
            'tools' => $tools,
798
            'defaults' => $defaults,
799
        ]);
800
    }
801
802
    #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])]
803
    public function moodleExportResources(int $node, Request $req): JsonResponse
804
    {
805
        // Enable/disable debug from request
806
        $this->setDebugFromRequest($req);
807
        $this->logDebug('[moodleExportResources] start', ['node' => $node]);
808
809
        try {
810
            // Normalize incoming tools from query (?tools[]=documents&tools[]=links ...)
811
            $selectedTools = $this->normalizeSelectedTools($req->query->all('tools'));
812
813
            // Default toolset tailored for Moodle export picker:
814
            $defaultToolsForMoodle = [
815
                'documents', 'links', 'forums',
816
                'quizzes', 'quiz_questions',
817
                'surveys', 'survey_questions',
818
                'learnpaths', 'learnpath_category',
819
                'works', 'glossary',
820
                'tool_intro',
821
                'course_descriptions',
822
                'attendance',
823
                'announcement',
824
                'events',
825
                'wiki',
826
                'thematic',
827
                'gradebook',
828
            ];
829
830
            // Use client tools if provided; otherwise our Moodle-safe defaults
831
            $tools = !empty($selectedTools) ? $selectedTools : $defaultToolsForMoodle;
832
833
            // Policy for this endpoint:
834
            //  - Never show gradebook
835
            //  - Always include tool_intro in the picker (harmless if empty)
836
            //$tools = array_values(array_diff($tools, ['gradebook']));
837
            if (!in_array('tool_intro', $tools, true)) {
838
            $tools = array_values(array_diff($tools, ['gradebook']));
839
            if (!\in_array('tool_intro', $tools, true)) {
840
                $tools[] = 'tool_intro';
841
            }
842
843
            $this->logDebug('[moodleExportResources] tools to build', $tools);
844
845
            // Build legacy Course snapshot from CURRENT course (not from a source course)
846
            $cb = new CourseBuilder();
847
            $cb->set_tools_to_build($tools);
848
            $course = $cb->build(0, api_get_course_id());
849
850
            // Build the UI-friendly tree
851
            $tree = $this->buildResourceTreeForVue($course);
852
853
            // Basic warnings for the client
854
            $warnings = empty($tree) ? ['This course has no resources.'] : [];
855
856
            // Some compact debug about the resulting tree
857
            if ($this->debug) {
858
                $this->logDebug(
859
                    '[moodleExportResources] tree summary',
860
                    array_map(
861
                        fn ($g) => [
862
                            'type' => $g['type'] ?? '',
863
                            'title' => $g['title'] ?? '',
864
                            'items' => isset($g['items']) ? \count((array) $g['items']) : null,
865
                            'children' => isset($g['children']) ? \count((array) $g['children']) : null,
866
                        ],
867
                        $tree
868
                    )
869
                );
870
            }
871
872
            return $this->json([
873
                'tree' => $tree,
874
                'warnings' => $warnings,
875
            ]);
876
        } catch (Throwable $e) {
877
            // Defensive error path
878
            $this->logDebug('[moodleExportResources] exception', [
879
                'message' => $e->getMessage(),
880
                'file' => $e->getFile().':'.$e->getLine(),
881
            ]);
882
883
            return $this->json([
884
                'error' => 'Failed to build resource tree for Moodle export.',
885
                'details' => $e->getMessage(),
886
            ], 500);
887
        }
888
    }
889
890
    #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])]
891
    public function moodleExportExecute(int $node, Request $req, UserRepository $users): BinaryFileResponse|JsonResponse
892
    {
893
        $this->setDebugFromRequest($req);
894
895
        $p = json_decode($req->getContent() ?: '{}', true) ?: [];
896
        $moodleVersion = (string) ($p['moodleVersion'] ?? '4');  // "3" | "4"
897
        $scope = (string) ($p['scope'] ?? 'full');       // "full" | "selected"
898
        $adminId = (int) ($p['adminId'] ?? 0);
899
        $adminLogin = trim((string) ($p['adminLogin'] ?? ''));
900
        $adminEmail = trim((string) ($p['adminEmail'] ?? ''));
901
        $selected = \is_array($p['resources'] ?? null) ? (array) $p['resources'] : [];
902
        $toolsInput = \is_array($p['tools'] ?? null) ? (array) $p['tools'] : [];
903
904
        if (!\in_array($moodleVersion, ['3', '4'], true)) {
905
            return $this->json(['error' => 'Unsupported Moodle version'], 400);
906
        }
907
        if ('selected' === $scope && empty($selected)) {
908
            return $this->json(['error' => 'No resources selected'], 400);
909
        }
910
911
        $defaultTools = [
912
            'documents', 'links', 'forums',
913
            'quizzes', 'quiz_questions',
914
            'surveys', 'survey_questions',
915
            'learnpaths', 'learnpath_category',
916
            'works', 'glossary',
917
            'course_descriptions',
918
            'attendance',
919
            'announcement',
920
            'events',
921
            'wiki',
922
            'thematic',
923
            'gradebook',
924
        ];
925
926
        $tools = $this->normalizeSelectedTools($toolsInput);
927
928
        // If scope=selected, merge inferred tools from selection
929
        if ('selected' === $scope) {
930
            $inferred = $this->inferToolsFromSelection($selected);
931
            $tools = $this->normalizeSelectedTools(array_merge($tools, $inferred));
932
        }
933
934
        // Remove unsupported tools
935
        $clientSentNoTools = empty($toolsInput);
936
        $useDefault = ('full' === $scope && $clientSentNoTools);
937
        $toolsToBuild = $useDefault ? $defaultTools : $tools;
938
939
        // Ensure "tool_intro" is present (append only if missing)
940
        if (!\in_array('tool_intro', $toolsToBuild, true)) {
941
            $toolsToBuild[] = 'tool_intro';
942
        }
943
944
        // Final dedupe/normalize
945
        $toolsToBuild = array_values(array_unique($toolsToBuild));
946
947
        $this->logDebug('[moodleExportExecute] course tools to build (final)', $toolsToBuild);
948
949
        if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
950
            $adm = $users->getDefaultAdminForExport();
951
            $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
952
            $adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
953
            $adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? '[email protected]');
954
        }
955
956
        $courseId = api_get_course_id();
957
        if (empty($courseId)) {
958
            return $this->json(['error' => 'No active course context'], 400);
959
        }
960
961
        $cb = new CourseBuilder();
962
        $cb->set_tools_to_build($toolsToBuild);
963
        $course = $cb->build(0, $courseId);
964
965
        if ('selected' === $scope) {
966
            $course = $this->filterLegacyCourseBySelection($course, $selected);
967
            if (empty($course->resources) || !\is_array($course->resources)) {
968
                return $this->json(['error' => 'Selection produced no resources to export'], 400);
969
            }
970
        }
971
972
        try {
973
            // === Export to Moodle MBZ ===
974
            $selectionMode = ('selected' === $scope);
975
            $exporter = new MoodleExport($course, $selectionMode);
976
            $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail);
977
978
            $exportDir = 'moodle_export_'.date('Ymd_His');
979
            $versionNum = ('3' === $moodleVersion) ? 3 : 4;
980
981
            $this->logDebug('[moodleExportExecute] starting exporter', [
982
                'courseId' => $courseId,
983
                'exportDir' => $exportDir,
984
                'versionNum' => $versionNum,
985
                'selection' => $selectionMode,
986
                'scope' => $scope,
987
            ]);
988
989
            $mbzPath = $exporter->export($courseId, $exportDir, $versionNum);
990
991
            if (!\is_string($mbzPath) || '' === $mbzPath || !is_file($mbzPath)) {
992
                return $this->json(['error' => 'Moodle export failed: artifact not found'], 500);
993
            }
994
995
            // Build download response
996
            $resp = new BinaryFileResponse($mbzPath);
997
            $resp->setContentDisposition(
998
                ResponseHeaderBag::DISPOSITION_ATTACHMENT,
999
                basename($mbzPath)
1000
            );
1001
            $resp->headers->set('X-Moodle-Version', (string) $versionNum);
1002
            $resp->headers->set('X-Export-Scope', $scope);
1003
            $resp->headers->set('X-Selection-Mode', $selectionMode ? '1' : '0');
1004
1005
            return $resp;
1006
        } catch (Throwable $e) {
1007
            $this->logDebug('[moodleExportExecute] exception', [
1008
                'message' => $e->getMessage(),
1009
                'code' => (int) $e->getCode(),
1010
            ]);
1011
1012
            return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
1013
        }
1014
    }
1015
1016
    /**
1017
     * Normalize tool list to supported ones and add implied dependencies.
1018
     *
1019
     * @param array<int,string>|null $tools
1020
     *
1021
     * @return string[]
1022
     */
1023
    private function normalizeSelectedTools(?array $tools): array
1024
    {
1025
        // Single list of supported tool buckets (must match CourseBuilder/exporters)
1026
        $all = [
1027
            'documents','links','quizzes','quiz_questions','surveys','survey_questions',
1028
            'announcement','events','course_descriptions','glossary','wiki','thematic',
1029
            'attendance','works','gradebook','learnpath_category','learnpaths','tool_intro','forums',
1030
            'documents', 'links', 'quizzes', 'quiz_questions', 'surveys', 'survey_questions',
1031
            'announcements', 'events', 'course_descriptions', 'glossary', 'wiki', 'thematic',
1032
            'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', 'tool_intro', 'forums',
1033
        ];
1034
1035
        // Implied dependencies
1036
        $deps = [
1037
            'quizzes' => ['quiz_questions'],
1038
            'surveys' => ['survey_questions'],
1039
            'learnpaths' => ['learnpath_category'],
1040
        ];
1041
1042
        $sel = \is_array($tools) ? array_values(array_intersect($tools, $all)) : [];
1043
1044
        foreach ($sel as $t) {
1045
            foreach ($deps[$t] ?? [] as $d) {
1046
                $sel[] = $d;
1047
            }
1048
        }
1049
1050
        // Unique and preserve a sane order based on $all
1051
        $sel = array_values(array_unique($sel));
1052
        usort($sel, static function ($a, $b) use ($all) {
1053
            return array_search($a, $all, true) <=> array_search($b, $all, true);
1054
        });
1055
1056
        return $sel;
1057
    }
1058
1059
    #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])]
1060
    public function cc13ExportOptions(int $node, Request $req): JsonResponse
1061
    {
1062
        $this->setDebugFromRequest($req);
1063
1064
        return $this->json([
1065
            'defaults' => ['scope' => 'full'],
1066
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
1067
            'message' => 'Common Cartridge 1.3: documents (webcontent) and links (HTML stub as webcontent). Forums not exported yet.',
1068
        ]);
1069
    }
1070
1071
    #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])]
1072
    public function cc13ExportResources(int $node, Request $req): JsonResponse
1073
    {
1074
        $this->setDebugFromRequest($req);
1075
1076
        $cb = new CourseBuilder();
1077
        $cb->set_tools_to_build(['documents', 'links', 'forums']);
1078
        $course = $cb->build(0, api_get_course_id());
1079
1080
        $treeAll = $this->buildResourceTreeForVue($course);
1081
        $tree = Cc13Capabilities::filterTree($treeAll);
1082
1083
        // Count exportables using "items"
1084
        $exportableCount = 0;
1085
        foreach ($tree as $group) {
1086
            if (empty($group['items']) || !\is_array($group['items'])) {
1087
                continue;
1088
            }
1089
1090
            if (($group['type'] ?? '') === 'forum') {
1091
                foreach ($group['items'] as $cat) {
1092
                    foreach (($cat['items'] ?? []) as $forumNode) {
1093
                        if (($forumNode['type'] ?? '') === 'forum') {
1094
                            $exportableCount++;
1095
                        }
1096
                    }
1097
                }
1098
            } else {
1099
                $exportableCount += \count($group['items'] ?? []);
1100
            }
1101
        }
1102
1103
        $warnings = [];
1104
        if (0 === $exportableCount) {
1105
            $warnings[] = 'This course has no CC 1.3 exportable resources (documents, links or forums).';
1106
        }
1107
1108
        return $this->json([
1109
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
1110
            'tree' => $tree,
1111
            'preview' => ['counts' => ['total' => $exportableCount]],
1112
            'warnings' => $warnings,
1113
        ]);
1114
    }
1115
1116
    #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])]
1117
    public function cc13ExportExecute(int $node, Request $req): JsonResponse
1118
    {
1119
        $payload = json_decode((string) $req->getContent(), true) ?: [];
1120
        // If the client sent "resources", treat as selected even if scope says "full".
1121
        $scope = (string) ($payload['scope'] ?? (!empty($payload['resources']) ? 'selected' : 'full'));
1122
        $selected = (array) ($payload['resources'] ?? []);
1123
1124
        // Normalize selection structure (documents/links/forums/…)
1125
        $normSel = Cc13Capabilities::filterSelection($selected);
1126
1127
        // Builder setup
1128
        $tools = ['documents', 'links', 'forums'];
1129
        $cb = new CourseBuilder();
1130
1131
        $selectionMode = false;
1132
1133
        try {
1134
            /** @var Course|null $courseFull */
1135
            $courseFull = null;
1136
1137
            if ('selected' === $scope) {
1138
                // Build a full snapshot first to expand any category-only selections.
1139
                $cbFull = new CourseBuilder();
1140
                $cbFull->set_tools_to_build($tools);
1141
                $courseFull = $cbFull->build(0, api_get_course_id());
1142
1143
                $expanded = $this->expandCc13SelectionFromCategories($courseFull, $normSel);
1144
1145
                // Build per-tool ID map for CourseBuilder
1146
                $map = [];
1147
                if (!empty($expanded['documents'])) {
1148
                    $map['documents'] = array_map('intval', array_keys($expanded['documents']));
1149
                }
1150
                if (!empty($expanded['links'])) {
1151
                    $map['links'] = array_map('intval', array_keys($expanded['links']));
1152
                }
1153
                if (!empty($expanded['forums'])) {
1154
                    $map['forums'] = array_map('intval', array_keys($expanded['forums']));
1155
                }
1156
1157
                if (empty($map)) {
1158
                    return $this->json(['error' => 'Please select at least one resource.'], 400);
1159
                }
1160
1161
                $cb->set_tools_to_build($tools);
1162
                $cb->set_tools_specific_id_list($map);
1163
                $selectionMode = true;
1164
            } else {
1165
                $cb->set_tools_to_build($tools);
1166
            }
1167
1168
            $course = $cb->build(0, api_get_course_id());
1169
1170
            // Safety net: if selection mode, ensure resources are filtered
1171
            if ($selectionMode) {
1172
                // Convert to the expected structure for filterCourseResources()
1173
                $safeSelected = [
1174
                    'documents' => array_fill_keys(array_map('intval', array_keys($normSel['documents'] ?? [])), true),
1175
                    'links' => array_fill_keys(array_map('intval', array_keys($normSel['links'] ?? [])), true),
1176
                    'forums' => array_fill_keys(array_map('intval', array_keys($normSel['forums'] ?? [])), true),
1177
                ];
1178
                // Also include expansions from categories
1179
                $fullSnapshot = $courseFull ?: $course;
1180
                $expandedAll = $this->expandCc13SelectionFromCategories($fullSnapshot, $normSel);
1181
                foreach (['documents', 'links', 'forums'] as $k) {
1182
                    if (!isset($expandedAll[$k])) {
1183
                        continue;
1184
                    }
1185
1186
                    foreach (array_keys($expandedAll[$k]) as $idStr) {
1187
                        $safeSelected[$k][(int) $idStr] = true;
1188
                    }
1189
                }
1190
1191
                $this->filterCourseResources($course, $safeSelected);
1192
                if (empty($course->resources) || !\is_array($course->resources)) {
1193
                    return $this->json(['error' => 'Nothing to export after filtering your selection.'], 400);
1194
                }
1195
            }
1196
1197
            $exporter = new Cc13Export($course, $selectionMode, /* debug */ false);
1198
            $imsccPath = $exporter->export(api_get_course_id());
1199
            $fileName = basename($imsccPath);
1200
1201
            $downloadUrl = $this->generateUrl(
1202
                'cm_cc13_export_download',
1203
                ['node' => $node],
1204
                UrlGeneratorInterface::ABSOLUTE_URL
1205
            ).'?file='.rawurlencode($fileName);
1206
1207
            return $this->json([
1208
                'ok' => true,
1209
                'file' => $fileName,
1210
                'downloadUrl' => $downloadUrl,
1211
                'message' => 'Export finished.',
1212
            ]);
1213
        } catch (RuntimeException $e) {
1214
            if (false !== stripos($e->getMessage(), 'Nothing to export')) {
1215
                return $this->json(['error' => 'Nothing to export (no compatible resources found).'], 400);
1216
            }
1217
1218
            return $this->json(['error' => 'CC 1.3 export failed: '.$e->getMessage()], 500);
1219
        }
1220
    }
1221
1222
    #[Route('/cc13/export/download', name: 'cc13_export_download', methods: ['GET'])]
1223
    public function cc13ExportDownload(int $node, Request $req): BinaryFileResponse|JsonResponse
1224
    {
1225
        // Validate the filename we will serve
1226
        $file = basename((string) $req->query->get('file', ''));
1227
        // Example pattern: ABC123_cc13_20251017_195455.imscc
1228
        if ('' === $file || !preg_match('/^[A-Za-z0-9_-]+_cc13_\d{8}_\d{6}\.imscc$/', $file)) {
1229
            return $this->json(['error' => 'Invalid file'], 400);
1230
        }
1231
1232
        $abs = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$file;
1233
        if (!is_file($abs)) {
1234
            return $this->json(['error' => 'File not found'], 404);
1235
        }
1236
1237
        // Stream file to the browser
1238
        $resp = new BinaryFileResponse($abs);
1239
        $resp->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file);
1240
        $resp->headers->set('Content-Type', 'application/vnd.ims.ccv1p3+imscc');
1241
1242
        return $resp;
1243
    }
1244
1245
    #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])]
1246
    public function cc13Import(int $node, Request $req): JsonResponse
1247
    {
1248
        $this->setDebugFromRequest($req);
1249
1250
        try {
1251
            $file = $req->files->get('file');
1252
            if (!$file || !$file->isValid()) {
1253
                return $this->json(['error' => 'Missing or invalid upload.'], 400);
1254
            }
1255
1256
            $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION));
1257
            if (!\in_array($ext, ['imscc', 'zip'], true)) {
1258
                return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 415);
1259
            }
1260
1261
            // Move to a temp file
1262
            $tmpZip = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.
1263
                'cc13_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)).'.'.$ext;
1264
            $file->move(\dirname($tmpZip), basename($tmpZip));
1265
1266
            // Extract
1267
            $extractDir = Imscc13Import::unzip($tmpZip);
1268
1269
            // Detect and validate format
1270
            $format = Imscc13Import::detectFormat($extractDir);
1271
            if (Imscc13Import::FORMAT_IMSCC13 !== $format) {
1272
                Imscc13Import::rrmdir($extractDir);
1273
                @unlink($tmpZip);
1274
1275
                return $this->json(['error' => 'This package is not a Common Cartridge 1.3.'], 400);
1276
            }
1277
1278
            // Execute import (creates Chamilo resources)
1279
            $importer = new Imscc13Import();
1280
            $importer->execute($extractDir);
1281
1282
            // Cleanup
1283
            Imscc13Import::rrmdir($extractDir);
1284
            @unlink($tmpZip);
1285
1286
            return $this->json([
1287
                'ok' => true,
1288
                'message' => 'CC 1.3 import completed successfully.',
1289
            ]);
1290
        } catch (Throwable $e) {
1291
            return $this->json([
1292
                'error' => 'CC 1.3 import failed: '.$e->getMessage(),
1293
            ], 500);
1294
        }
1295
    }
1296
1297
    #[Route(
1298
        '/import/{backupId}/diagnose',
1299
        name: 'import_diagnose',
1300
        requirements: ['backupId' => '.+'],
1301
        methods: ['GET']
1302
    )]
1303
    public function importDiagnose(int $node, string $backupId, Request $req): JsonResponse
1304
    {
1305
        $this->setDebugFromRequest($req);
1306
        $this->logDebug('[importDiagnose] begin', ['node' => $node, 'backupId' => $backupId]);
1307
1308
        try {
1309
            // Resolve absolute path of the uploaded/selected backup
1310
            $path = $this->resolveBackupPath($backupId);
1311
            if (!is_file($path)) {
1312
                return $this->json(['error' => 'Backup file not found', 'path' => $path], 404);
1313
            }
1314
1315
            // Read course_info.dat bytes from ZIP
1316
            $ci = $this->readCourseInfoFromZip($path);
1317
            if (empty($ci['ok'])) {
1318
                $this->logDebug('[importDiagnose] course_info.dat not found or unreadable', $ci);
1319
1320
                return $this->json([
1321
                    'meta' => [
1322
                        'backupId' => $backupId,
1323
                        'path' => $path,
1324
                    ],
1325
                    'zip' => [
1326
                        'error' => $ci['error'] ?? 'unknown error',
1327
                        'zip_list_sample' => $ci['zip_list_sample'] ?? [],
1328
                        'num_files' => $ci['num_files'] ?? null,
1329
                    ],
1330
                ], 200);
1331
            }
1332
1333
            $raw = (string) $ci['data'];
1334
            $size = (int) ($ci['size'] ?? \strlen($raw));
1335
            $md5 = md5($raw);
1336
1337
            // Detect & decode content
1338
            $probe = $this->decodeCourseInfo($raw);
1339
1340
            // Build a tiny scan snapshot
1341
            $scan = [
1342
                'has_graph' => false,
1343
                'resources_keys' => [],
1344
                'note' => 'No graph parsed',
1345
            ];
1346
1347
            if (!empty($probe['is_serialized']) && isset($probe['value']) && \is_object($probe['value'])) {
1348
                /** @var object $course */
1349
                $course = $probe['value'];
1350
                $scan['has_graph'] = true;
1351
                $scan['resources_keys'] = (isset($course->resources) && \is_array($course->resources))
1352
                    ? array_keys($course->resources)
1353
                    : [];
1354
                $scan['note'] = 'Parsed PHP serialized graph';
1355
            } elseif (!empty($probe['is_json']) && \is_array($probe['json_preview'])) {
1356
                $jp = $probe['json_preview'];
1357
                $scan['has_graph'] = true;
1358
                $scan['resources_keys'] = (isset($jp['resources']) && \is_array($jp['resources']))
1359
                    ? array_keys($jp['resources'])
1360
                    : [];
1361
                $scan['note'] = 'Parsed JSON document';
1362
            }
1363
1364
            $probeOut = $probe;
1365
            unset($probeOut['value'], $probeOut['decoded']);
1366
1367
            $out = [
1368
                'meta' => [
1369
                    'backupId' => $backupId,
1370
                    'path' => $path,
1371
                    'node' => $node,
1372
                ],
1373
                'zip' => [
1374
                    'name' => $ci['name'] ?? null,
1375
                    'index' => $ci['index'] ?? null,
1376
                ],
1377
                'course_info_dat' => [
1378
                    'size_bytes' => $size,
1379
                    'md5' => $md5,
1380
                ],
1381
                'probe' => $probeOut,
1382
                'scan' => $scan,
1383
            ];
1384
1385
            $this->logDebug('[importDiagnose] done', [
1386
                'encoding' => $probeOut['encoding'] ?? null,
1387
                'has_graph' => $scan['has_graph'],
1388
                'resources_keys' => $scan['resources_keys'],
1389
            ]);
1390
1391
            return $this->json($out);
1392
        } catch (Throwable $e) {
1393
            $this->logDebug('[importDiagnose] exception', ['message' => $e->getMessage()]);
1394
1395
            return $this->json([
1396
                'error' => 'Diagnosis failed: '.$e->getMessage(),
1397
            ], 500);
1398
        }
1399
    }
1400
1401
    /**
1402
     * Try to detect and decode course_info.dat content.
1403
     * Hardened: preprocess typed-prop numeric strings and register legacy aliases
1404
     * before attempting unserialize. Falls back to relaxed mode to avoid typed
1405
     * property crashes during diagnosis.
1406
     */
1407
    private function decodeCourseInfo(string $raw): array
1408
    {
1409
        $r = [
1410
            'encoding' => 'raw',
1411
            'decoded_len' => \strlen($raw),
1412
            'magic_hex' => bin2hex(substr($raw, 0, 8)),
1413
            'magic_ascii' => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)),
1414
            'steps' => [],
1415
            'decoded' => null,
1416
            'is_serialized' => false,
1417
            'is_json' => false,
1418
            'json_preview' => null,
1419
        ];
1420
1421
        $isJson = static function (string $s): bool {
1422
            $t = ltrim($s);
1423
1424
            return '' !== $t && ('{' === $t[0] || '[' === $t[0]);
1425
        };
1426
1427
        // Centralized tolerant unserialize with typed-props preprocessing
1428
        $tryUnserializeTolerant = function (string $s, string $label) use (&$r) {
1429
            $ok = false;
1430
            $val = null;
1431
            $err = null;
1432
            $relaxed = false;
1433
1434
            // Ensure legacy aliases and coerce numeric strings before unserialize
1435
            try {
1436
                CourseArchiver::ensureLegacyAliases();
1437
            } catch (Throwable) { /* ignore */
1438
            }
1439
1440
            try {
1441
                $s = CourseArchiver::preprocessSerializedPayloadForTypedProps($s);
1442
            } catch (Throwable) { /* ignore */
1443
            }
1444
1445
            // Strict mode
1446
            set_error_handler(static function (): void {});
1447
1448
            try {
1449
                $val = @unserialize($s, ['allowed_classes' => true]);
1450
                $ok = (false !== $val) || ('b:0;' === trim($s));
1451
            } catch (Throwable $e) {
1452
                $err = $e->getMessage();
1453
                $ok = false;
1454
            } finally {
1455
                restore_error_handler();
1456
            }
1457
            $r['steps'][] = ['action' => "unserialize[$label][strict]", 'ok' => $ok, 'error' => $err];
1458
1459
            // Relaxed fallback (no class instantiation) + deincomplete to stdClass
1460
            if (!$ok) {
1461
                $err2 = null;
1462
                set_error_handler(static function (): void {});
1463
1464
                try {
1465
                    $tmp = @unserialize($s, ['allowed_classes' => false]);
1466
                    if (false !== $tmp || 'b:0;' === trim($s)) {
1467
                        $val = $this->deincomplete($tmp);
1468
                        $ok = true;
1469
                        $relaxed = true;
1470
                        $err = null;
1471
                    }
1472
                } catch (Throwable $e2) {
1473
                    $err2 = $e2->getMessage();
1474
                } finally {
1475
                    restore_error_handler();
1476
                }
1477
                $r['steps'][] = ['action' => "unserialize[$label][relaxed]", 'ok' => $ok, 'error' => $err2];
1478
            }
1479
1480
            if ($ok) {
1481
                $r['is_serialized'] = true;
1482
                $r['decoded'] = null; // keep payload minimal
1483
                $r['used_relaxed'] = $relaxed;
1484
1485
                return $val;
1486
            }
1487
1488
            return null;
1489
        };
1490
1491
        // 0) JSON as-is?
1492
        if ($isJson($raw)) {
1493
            $r['encoding'] = 'json';
1494
            $r['is_json'] = true;
1495
            $r['json_preview'] = json_decode($raw, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1496
1497
            return $r;
1498
        }
1499
1500
        // Direct PHP serialize (strict then relaxed, after preprocessing)
1501
        if (($u = $tryUnserializeTolerant($raw, 'raw')) !== null) {
1502
            $r['encoding'] = 'php-serialize';
1503
1504
            return $r + ['value' => $u];
1505
        }
1506
1507
        // GZIP
1508
        if (0 === strncmp($raw, "\x1F\x8B", 2)) {
1509
            $dec = @gzdecode($raw);
1510
            $r['steps'][] = ['action' => 'gzdecode', 'ok' => false !== $dec];
1511
            if (false !== $dec) {
1512
                if ($isJson($dec)) {
1513
                    $r['encoding'] = 'gzip+json';
1514
                    $r['is_json'] = true;
1515
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1516
1517
                    return $r;
1518
                }
1519
                if (($u = $tryUnserializeTolerant($dec, 'gzip')) !== null) {
1520
                    $r['encoding'] = 'gzip+php-serialize';
1521
1522
                    return $r + ['value' => $u];
1523
                }
1524
            }
1525
        }
1526
1527
        // ZLIB/DEFLATE
1528
        $z2 = substr($raw, 0, 2);
1529
        if ("\x78\x9C" === $z2 || "\x78\xDA" === $z2) {
1530
            $dec = @gzuncompress($raw);
1531
            $r['steps'][] = ['action' => 'gzuncompress', 'ok' => false !== $dec];
1532
            if (false !== $dec) {
1533
                if ($isJson($dec)) {
1534
                    $r['encoding'] = 'zlib+json';
1535
                    $r['is_json'] = true;
1536
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1537
1538
                    return $r;
1539
                }
1540
                if (($u = $tryUnserializeTolerant($dec, 'zlib')) !== null) {
1541
                    $r['encoding'] = 'zlib+php-serialize';
1542
1543
                    return $r + ['value' => $u];
1544
                }
1545
            }
1546
            $dec2 = @gzinflate($raw);
1547
            $r['steps'][] = ['action' => 'gzinflate', 'ok' => false !== $dec2];
1548
            if (false !== $dec2) {
1549
                if ($isJson($dec2)) {
1550
                    $r['encoding'] = 'deflate+json';
1551
                    $r['is_json'] = true;
1552
                    $r['json_preview'] = json_decode($dec2, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1553
1554
                    return $r;
1555
                }
1556
                if (($u = $tryUnserializeTolerant($dec2, 'deflate')) !== null) {
1557
                    $r['encoding'] = 'deflate+php-serialize';
1558
1559
                    return $r + ['value' => $u];
1560
                }
1561
            }
1562
        }
1563
1564
        // BASE64 (e.g. "Tzo0ODoi..." -> base64('O:48:"Chamilo...'))
1565
        if (preg_match('~^[A-Za-z0-9+/=\r\n]+$~', $raw)) {
1566
            $dec = base64_decode($raw, true);
1567
            $r['steps'][] = ['action' => 'base64_decode', 'ok' => false !== $dec];
1568
            if (false !== $dec) {
1569
                if ($isJson($dec)) {
1570
                    $r['encoding'] = 'base64(json)';
1571
                    $r['is_json'] = true;
1572
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1573
1574
                    return $r;
1575
                }
1576
                if (($u = $tryUnserializeTolerant($dec, 'base64')) !== null) {
1577
                    $r['encoding'] = 'base64(php-serialize)';
1578
1579
                    return $r + ['value' => $u];
1580
                }
1581
                // base64 + gzip nested
1582
                if (0 === strncmp($dec, "\x1F\x8B", 2)) {
1583
                    $dec2 = @gzdecode($dec);
1584
                    $r['steps'][] = ['action' => 'base64+gzdecode', 'ok' => false !== $dec2];
1585
                    if (false !== $dec2 && ($u = $tryUnserializeTolerant($dec2, 'base64+gzip')) !== null) {
1586
                        $r['encoding'] = 'base64(gzip+php-serialize)';
1587
1588
                        return $r + ['value' => $u];
1589
                    }
1590
                }
1591
            }
1592
        }
1593
1594
        // Nested ZIP?
1595
        if (0 === strncmp($raw, "PK\x03\x04", 4)) {
1596
            $r['encoding'] = 'nested-zip';
1597
        }
1598
1599
        return $r;
1600
    }
1601
1602
    /**
1603
     * Replace any __PHP_Incomplete_Class instances with stdClass (deep).
1604
     * Also traverses arrays and objects (diagnostics-only).
1605
     */
1606
    private function deincomplete(mixed $v): mixed
1607
    {
1608
        if ($v instanceof __PHP_Incomplete_Class) {
1609
            $o = new stdClass();
1610
            foreach (get_object_vars($v) as $k => $vv) {
1611
                $o->{$k} = $this->deincomplete($vv);
1612
            }
1613
1614
            return $o;
1615
        }
1616
        if (\is_array($v)) {
1617
            foreach ($v as $k => $vv) {
1618
                $v[$k] = $this->deincomplete($vv);
1619
            }
1620
1621
            return $v;
1622
        }
1623
        if (\is_object($v)) {
1624
            foreach (get_object_vars($v) as $k => $vv) {
1625
                $v->{$k} = $this->deincomplete($vv);
1626
            }
1627
1628
            return $v;
1629
        }
1630
1631
        return $v;
1632
    }
1633
1634
    /**
1635
     * Return [ok, name, index, size, data] for the first matching entry of course_info.dat (case-insensitive).
1636
     * Also tries common subpaths, e.g., "course/course_info.dat".
1637
     */
1638
    private function readCourseInfoFromZip(string $zipPath): array
1639
    {
1640
        $candidates = [
1641
            'course_info.dat',
1642
            'course/course_info.dat',
1643
            'backup/course_info.dat',
1644
        ];
1645
1646
        $zip = new ZipArchive();
1647
        if (true !== ($err = $zip->open($zipPath))) {
1648
            return ['ok' => false, 'error' => 'Failed to open ZIP (ZipArchive::open error '.$err.')'];
1649
        }
1650
1651
        // First: direct name lookup (case-insensitive)
1652
        $foundIdx = null;
1653
        $foundName = null;
1654
1655
        for ($i = 0; $i < $zip->numFiles; $i++) {
1656
            $st = $zip->statIndex($i);
1657
            if (!$st || !isset($st['name'])) {
1658
                continue;
1659
            }
1660
            $name = (string) $st['name'];
1661
            $base = strtolower(basename($name));
1662
            if ('course_info.dat' === $base) {
1663
                $foundIdx = $i;
1664
                $foundName = $name;
1665
1666
                break;
1667
            }
1668
        }
1669
1670
        // Try specific candidate paths if direct scan failed
1671
        if (null === $foundIdx) {
1672
            foreach ($candidates as $cand) {
1673
                $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
1674
                if (false !== $idx) {
1675
                    $foundIdx = $idx;
1676
                    $foundName = $zip->getNameIndex($idx);
1677
1678
                    break;
1679
                }
1680
            }
1681
        }
1682
1683
        if (null === $foundIdx) {
1684
            // Build a short listing for debugging
1685
            $list = [];
1686
            $limit = min($zip->numFiles, 200);
1687
            for ($i = 0; $i < $limit; $i++) {
1688
                $n = $zip->getNameIndex($i);
1689
                if (false !== $n) {
1690
                    $list[] = $n;
1691
                }
1692
            }
1693
            $zip->close();
1694
1695
            return [
1696
                'ok' => false,
1697
                'error' => 'course_info.dat not found in archive',
1698
                'zip_list_sample' => $list,
1699
                'num_files' => $zip->numFiles,
1700
            ];
1701
        }
1702
1703
        $stat = $zip->statIndex($foundIdx);
1704
        $size = (int) ($stat['size'] ?? 0);
1705
        $fp = $zip->getStream($foundName);
1706
        if (!$fp) {
1707
            $zip->close();
1708
1709
            return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)'];
1710
        }
1711
1712
        $data = stream_get_contents($fp);
1713
        fclose($fp);
1714
        $zip->close();
1715
1716
        if (!\is_string($data)) {
1717
            return ['ok' => false, 'error' => 'Failed to read course_info.dat contents'];
1718
        }
1719
1720
        return [
1721
            'ok' => true,
1722
            'name' => $foundName,
1723
            'index' => $foundIdx,
1724
            'size' => $size,
1725
            'data' => $data,
1726
        ];
1727
    }
1728
1729
    /**
1730
     * Build a Vue-friendly tree from legacy Course.
1731
     */
1732
    private function buildResourceTreeForVue(object $course): array
1733
    {
1734
        if ($this->debug) {
1735
            $this->logDebug('[buildResourceTreeForVue] start');
1736
        }
1737
1738
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
1739
            ? $course->resources
1740
            : [];
1741
1742
        $legacyTitles = [];
1743
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
1744
            /** @var array<string,string> $legacyTitles */
1745
            $legacyTitles = CourseSelectForm::getResourceTitleList();
1746
        }
1747
        $fallbackTitles = $this->getDefaultTypeTitles();
1748
        $skipTypes = $this->getSkipTypeKeys();
1749
1750
        $tree = [];
1751
1752
        // Documents block
1753
        if (!empty($resources['document']) && \is_array($resources['document'])) {
1754
            $docs = $resources['document'];
1755
1756
            $normalize = function (string $rawPath, string $title, string $filetype): string {
1757
                $p = trim($rawPath, '/');
1758
                $p = (string) preg_replace('~^(?:document/)+~i', '', $p);
1759
                $parts = array_values(array_filter(explode('/', $p), 'strlen'));
1760
1761
                // host
1762
                if (!empty($parts) && ('localhost' === $parts[0] || str_contains($parts[0], '.'))) {
1763
                    array_shift($parts);
1764
                }
1765
                // course-code
1766
                if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) {
1767
                    array_shift($parts);
1768
                }
1769
1770
                $clean = implode('/', $parts);
1771
                if ('' === $clean && 'folder' !== $filetype) {
1772
                    $clean = $title;
1773
                }
1774
                if ('folder' === $filetype) {
1775
                    $clean = rtrim($clean, '/').'/';
1776
                }
1777
1778
                return $clean;
1779
            };
1780
1781
            $folderIdByPath = [];
1782
            foreach ($docs as $obj) {
1783
                if (!\is_object($obj)) {
1784
                    continue;
1785
                }
1786
                $ft = (string) ($obj->filetype ?? $obj->file_type ?? '');
1787
                if ('folder' !== $ft) {
1788
                    continue;
1789
                }
1790
                $rel = $normalize((string) $obj->path, (string) $obj->title, $ft);
1791
                $key = rtrim($rel, '/');
1792
                if ('' !== $key) {
1793
                    $folderIdByPath[strtolower($key)] = (int) $obj->source_id;
1794
                }
1795
            }
1796
1797
            $docRoot = [];
1798
            $findChild = static function (array &$children, string $label): ?int {
1799
                foreach ($children as $i => $n) {
1800
                    if ((string) ($n['label'] ?? '') === $label) {
1801
                        return $i;
1802
                    }
1803
                }
1804
1805
                return null;
1806
            };
1807
1808
            foreach ($docs as $obj) {
1809
                if (!\is_object($obj)) {
1810
                    continue;
1811
                }
1812
1813
                $title = (string) $obj->title;
1814
                $filetype = (string) ($obj->filetype ?? $obj->file_type ?? '');
1815
                $rel = $normalize((string) $obj->path, $title, $filetype);
1816
                $parts = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen'));
1817
1818
                $cursor = &$docRoot;
1819
                $soFar = '';
1820
                $total = \count($parts);
1821
1822
                for ($i = 0; $i < $total; $i++) {
1823
                    $seg = $parts[$i];
1824
                    $isLast = ($i === $total - 1);
1825
                    $isFolder = (!$isLast) || ('folder' === $filetype);
1826
1827
                    $soFar = ltrim($soFar.'/'.$seg, '/');
1828
                    $label = $seg.($isFolder ? '/' : '');
1829
1830
                    $idx = $findChild($cursor, $label);
1831
                    if (null === $idx) {
1832
                        if ($isFolder) {
1833
                            $folderId = $folderIdByPath[strtolower($soFar)] ?? null;
1834
                            $node = [
1835
                                'id' => $folderId ?? ('dir:'.$soFar),
1836
                                'label' => $label,
1837
                                'selectable' => true,
1838
                                'children' => [],
1839
                            ];
1840
                        } else {
1841
                            $node = [
1842
                                'id' => (int) $obj->source_id,
1843
                                'label' => $label,
1844
                                'selectable' => true,
1845
                            ];
1846
                        }
1847
                        $cursor[] = $node;
1848
                        $idx = \count($cursor) - 1;
1849
                    }
1850
1851
                    if ($isFolder) {
1852
                        if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) {
1853
                            $cursor[$idx]['children'] = [];
1854
                        }
1855
                        $cursor = &$cursor[$idx]['children'];
1856
                    }
1857
                }
1858
            }
1859
1860
            $sortTree = null;
1861
            $sortTree = function (array &$nodes) use (&$sortTree): void {
1862
                usort($nodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1863
                foreach ($nodes as &$n) {
1864
                    if (isset($n['children']) && \is_array($n['children'])) {
1865
                        $sortTree($n['children']);
1866
                    }
1867
                }
1868
            };
1869
            $sortTree($docRoot);
1870
1871
            $tree[] = [
1872
                'type' => 'document',
1873
                'title' => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'),
1874
                'children' => $docRoot,
1875
            ];
1876
1877
            $skipTypes['document'] = true;
1878
        }
1879
1880
        // Forums block
1881
        $hasForumData =
1882
            (!empty($resources['forum']) || !empty($resources['Forum']))
1883
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1884
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1885
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1886
1887
        if ($hasForumData) {
1888
            $tree[] = $this->buildForumTreeForVue(
1889
                $course,
1890
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1891
            );
1892
            $skipTypes['forum'] = true;
1893
            $skipTypes['forum_category'] = true;
1894
            $skipTypes['forum_topic'] = true;
1895
            $skipTypes['forum_post'] = true;
1896
            $skipTypes['thread'] = true;
1897
            $skipTypes['post'] = true;
1898
        }
1899
1900
        // Links block (Category → Link)
1901
        $hasLinkData =
1902
            (!empty($resources['link']) || !empty($resources['Link']))
1903
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1904
1905
        if ($hasLinkData) {
1906
            $tree[] = $this->buildLinkTreeForVue(
1907
                $course,
1908
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1909
            );
1910
            $skipTypes['link'] = true;
1911
            $skipTypes['link_category'] = true;
1912
        }
1913
1914
        foreach ($resources as $rawType => $items) {
1915
            if (!\is_array($items) || empty($items)) {
1916
                continue;
1917
            }
1918
            $typeKey = $this->normalizeTypeKey($rawType);
1919
            if (isset($skipTypes[$typeKey])) {
1920
                continue;
1921
            }
1922
1923
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1924
            $group = [
1925
                'type' => $typeKey,
1926
                'title' => (string) $groupTitle,
1927
                'items' => [],
1928
            ];
1929
1930
            if ('gradebook' === $typeKey) {
1931
                $group['items'][] = [
1932
                    'id' => 'all',
1933
                    'label' => 'Gradebook (all)',
1934
                    'extra' => new stdClass(),
1935
                    'selectable' => true,
1936
                ];
1937
                $tree[] = $group;
1938
1939
                continue;
1940
            }
1941
1942
            foreach ($items as $id => $obj) {
1943
                if (!\is_object($obj)) {
1944
                    continue;
1945
                }
1946
1947
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1948
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1949
                    continue;
1950
                }
1951
1952
                if (!$this->isSelectableItem($typeKey, $obj)) {
1953
                    continue;
1954
                }
1955
1956
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1957
1958
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1959
                    $label = $idKey;
1960
                }
1961
1962
                $extra = $this->buildExtra($typeKey, $obj);
1963
1964
                $group['items'][] = [
1965
                    'id' => $idKey,
1966
                    'label' => $label,
1967
                    'extra' => $extra ?: new stdClass(),
1968
                    'selectable' => true,
1969
                ];
1970
            }
1971
1972
            if (!empty($group['items'])) {
1973
                usort(
1974
                    $group['items'],
1975
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1976
                );
1977
                $tree[] = $group;
1978
            }
1979
        }
1980
1981
        // Preferred order
1982
        $preferredOrder = [
1983
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
1984
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'events', 'tool_intro', 'gradebook',
1985
        ];
1986
        usort($tree, static function ($a, $b) use ($preferredOrder) {
1987
            $ia = array_search($a['type'], $preferredOrder, true);
1988
            $ib = array_search($b['type'], $preferredOrder, true);
1989
            if (false !== $ia && false !== $ib) {
1990
                return $ia <=> $ib;
1991
            }
1992
            if (false !== $ia) {
1993
                return -1;
1994
            }
1995
            if (false !== $ib) {
1996
                return 1;
1997
            }
1998
1999
            return strcasecmp($a['title'], $b['title']);
2000
        });
2001
2002
        if ($this->debug) {
2003
            $this->logDebug(
2004
                '[buildResourceTreeForVue] end groups',
2005
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? []), 'children' => \count($g['children'] ?? [])], $tree)
2006
            );
2007
        }
2008
2009
        return $tree;
2010
    }
2011
2012
    /**
2013
     * Build forum tree (Category → Forum → Topic) for the UI.
2014
     * Uses only "items" (no "children") and sets UI hints (has_children, item_count).
2015
     */
2016
    private function buildForumTreeForVue(object $course, string $groupTitle): array
2017
    {
2018
        $this->logDebug('[buildForumTreeForVue] start');
2019
2020
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2021
2022
        // Buckets (defensive: accept legacy casings / aliases)
2023
        $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
2024
        $forumRaw = $res['forum'] ?? $res['Forum'] ?? [];
2025
        $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []);
2026
        $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []);
2027
2028
        $this->logDebug('[buildForumTreeForVue] raw counts', [
2029
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
2030
            'forums' => \is_array($forumRaw) ? \count($forumRaw) : 0,
2031
            'topics' => \is_array($topicRaw) ? \count($topicRaw) : 0,
2032
            'posts' => \is_array($postRaw) ? \count($postRaw) : 0,
2033
        ]);
2034
2035
        // Quick classifiers (defensive)
2036
        $isForum = function (object $o): bool {
2037
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2038
            if (isset($e->forum_title) && \is_string($e->forum_title)) {
2039
                return true;
2040
            }
2041
            if (isset($e->default_view) || isset($e->allow_anonymous)) {
2042
                return true;
2043
            }
2044
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) {
2045
                return true;
2046
            }
2047
2048
            return false;
2049
        };
2050
        $isTopic = function (object $o): bool {
2051
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2052
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) {
2053
                return true;
2054
            }
2055
            if (isset($e->forum_id) && !isset($e->forum_title)) {
2056
                return true;
2057
            }
2058
2059
            return false;
2060
        };
2061
        $getForumCategoryId = function (object $forum): int {
2062
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
2063
            $cid = (int) ($e->forum_category ?? 0);
2064
            if ($cid <= 0) {
2065
                $cid = (int) ($e->forum_category_id ?? 0);
2066
            }
2067
            if ($cid <= 0) {
2068
                $cid = (int) ($e->category_id ?? 0);
2069
            }
2070
2071
            return $cid;
2072
        };
2073
2074
        // Build categories
2075
        $cats = [];
2076
        foreach ($catRaw as $id => $obj) {
2077
            $id = (int) $id;
2078
            if ($id <= 0 || !\is_object($obj)) {
2079
                continue;
2080
            }
2081
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
2082
            $cats[$id] = [
2083
                'id' => $id,
2084
                'type' => 'forum_category',
2085
                'label' => ('' !== $label ? $label : 'Category #'.$id).'/',
2086
                'selectable' => true,
2087
                'items' => [],
2088
                'has_children' => false,
2089
                'item_count' => 0,
2090
                'extra' => ['filetype' => 'folder'],
2091
            ];
2092
        }
2093
        // Virtual "Uncategorized"
2094
        $uncatKey = -9999;
2095
        if (!isset($cats[$uncatKey])) {
2096
            $cats[$uncatKey] = [
2097
                'id' => $uncatKey,
2098
                'type' => 'forum_category',
2099
                'label' => 'Uncategorized/',
2100
                'selectable' => true,
2101
                'items' => [],
2102
                '_virtual' => true,
2103
                'has_children' => false,
2104
                'item_count' => 0,
2105
                'extra' => ['filetype' => 'folder'],
2106
            ];
2107
        }
2108
2109
        // Forums
2110
        $forums = [];
2111
        foreach ($forumRaw as $id => $obj) {
2112
            $id = (int) $id;
2113
            if ($id <= 0 || !\is_object($obj)) {
2114
                continue;
2115
            }
2116
            if (!$isForum($obj)) {
2117
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]);
2118
2119
                continue;
2120
            }
2121
            $forums[$id] = $this->objectEntity($obj);
2122
        }
2123
2124
        // Topics (+ post counts)
2125
        $topics = [];
2126
        $postCountByTopic = [];
2127
        foreach ($topicRaw as $id => $obj) {
2128
            $id = (int) $id;
2129
            if ($id <= 0 || !\is_object($obj)) {
2130
                continue;
2131
            }
2132
            if ($isForum($obj) && !$isTopic($obj)) {
2133
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]);
2134
2135
                continue;
2136
            }
2137
            if (!$isTopic($obj)) {
2138
                continue;
2139
            }
2140
            $topics[$id] = $this->objectEntity($obj);
2141
        }
2142
        foreach ($postRaw as $id => $obj) {
2143
            $id = (int) $id;
2144
            if ($id <= 0 || !\is_object($obj)) {
2145
                continue;
2146
            }
2147
            $e = $this->objectEntity($obj);
2148
            $tid = (int) ($e->thread_id ?? 0);
2149
            if ($tid > 0) {
2150
                $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1;
2151
            }
2152
        }
2153
2154
        // Attach topics to forums and forums to categories
2155
        foreach ($forums as $fid => $f) {
2156
            $catId = $getForumCategoryId($f);
2157
            if (!isset($cats[$catId])) {
2158
                $catId = $uncatKey;
2159
            }
2160
2161
            $forumNode = [
2162
                'id' => $fid,
2163
                'type' => 'forum',
2164
                'label' => $this->resolveItemLabel('forum', $f, $fid),
2165
                'extra' => $this->buildExtra('forum', $f) ?: new stdClass(),
2166
                'selectable' => true,
2167
                'items' => [],
2168
                // UI hints
2169
                'has_children' => false,
2170
                'item_count' => 0,
2171
                'ui_depth' => 2,
2172
            ];
2173
2174
            foreach ($topics as $tid => $t) {
2175
                if ((int) ($t->forum_id ?? 0) !== $fid) {
2176
                    continue;
2177
                }
2178
2179
                $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
2180
                $date = (string) ($t->thread_date ?? '');
2181
                $nPosts = (int) ($postCountByTopic[$tid] ?? 0);
2182
2183
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
2184
                $meta = [];
2185
                if ('' !== $author) {
2186
                    $meta[] = $author;
2187
                }
2188
                if ('' !== $date) {
2189
                    $meta[] = $date;
2190
                }
2191
                if ($meta) {
2192
                    $topicLabel .= ' ('.implode(', ', $meta).')';
2193
                }
2194
                if ($nPosts > 0) {
2195
                    $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's');
2196
                }
2197
2198
                $forumNode['items'][] = [
2199
                    'id' => $tid,
2200
                    'type' => 'forum_topic',
2201
                    'label' => $topicLabel,
2202
                    'extra' => new stdClass(),
2203
                    'selectable' => true,
2204
                    'ui_depth' => 3,
2205
                    'item_count' => 0,
2206
                ];
2207
            }
2208
2209
            if (!empty($forumNode['items'])) {
2210
                usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2211
                $forumNode['has_children'] = true;
2212
                $forumNode['item_count'] = \count($forumNode['items']);
2213
            }
2214
2215
            $cats[$catId]['items'][] = $forumNode;
2216
        }
2217
2218
        // Remove empty virtual category; sort forums inside each category
2219
        $catNodes = array_values(array_filter($cats, static function ($c) {
2220
            if (!empty($c['_virtual']) && empty($c['items'])) {
2221
                return false;
2222
            }
2223
2224
            return true;
2225
        }));
2226
2227
        // Flatten stray forums (defensive) and finalize UI hints
2228
        foreach ($catNodes as &$cat) {
2229
            if (!empty($cat['items'])) {
2230
                $lift = [];
2231
                foreach ($cat['items'] as &$forumNode) {
2232
                    if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) {
2233
                        continue;
2234
                    }
2235
                    $keep = [];
2236
                    foreach ($forumNode['items'] as $child) {
2237
                        if (($child['type'] ?? '') === 'forum') {
2238
                            $lift[] = $child;
2239
                            $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [
2240
                                'parent_forum_id' => $forumNode['id'] ?? null,
2241
                                'lifted_forum_id' => $child['id'] ?? null,
2242
                                'cat_id' => $cat['id'] ?? null,
2243
                            ]);
2244
                        } else {
2245
                            $keep[] = $child;
2246
                        }
2247
                    }
2248
                    $forumNode['items'] = $keep;
2249
                    $forumNode['has_children'] = !empty($keep);
2250
                    $forumNode['item_count'] = \count($keep);
2251
                }
2252
                unset($forumNode);
2253
2254
                foreach ($lift as $n) {
2255
                    $cat['items'][] = $n;
2256
                }
2257
                usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2258
            }
2259
2260
            // UI hints for category
2261
            $cat['has_children'] = !empty($cat['items']);
2262
            $cat['item_count'] = \count($cat['items'] ?? []);
2263
        }
2264
        unset($cat);
2265
2266
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
2267
2268
        return [
2269
            'type' => 'forum',
2270
            'title' => $groupTitle,
2271
            'items' => $catNodes,
2272
        ];
2273
    }
2274
2275
    /**
2276
     * Canonicalizes a resource/bucket key used anywhere in the flow (UI type, snapshot key, etc.)
2277
     * Keep this small and stable; only map well-known aliases to the canonical snapshot keys we expect.
2278
     */
2279
    private function normalizeTypeKey(string $key): string
2280
    {
2281
        $k = strtolower(trim($key));
2282
2283
        // Documents
2284
        if (in_array($k, ['document','documents'], true)) {
2285
            return 'document';
2286
        }
2287
2288
        // Links
2289
        if (in_array($k, ['link','links'], true)) {
2290
            return 'link';
2291
        }
2292
        if (in_array($k, ['link_category','linkcategory','link_categories'], true)) {
2293
            return 'link_category';
2294
        }
2295
2296
        // Forums
2297
        if ($k === 'forum_category' || $k === 'forumcategory') {
2298
            return 'Forum_Category';
2299
        }
2300
        if ($k === 'forums') {
2301
            return 'forum';
2302
        }
2303
2304
        // Announcements / News
2305
        if (in_array($k, ['announcement','announcements','news'], true)) {
2306
            return 'announcement';
2307
        }
2308
2309
        // Attendance
2310
        if (in_array($k, ['attendance','attendances'], true)) {
2311
            return 'attendance';
2312
        }
2313
2314
        // Course descriptions
2315
        if (in_array($k, ['course_description','course_descriptions','description','descriptions'], true)) {
2316
            return 'course_descriptions';
2317
        }
2318
2319
        // Events / Calendar
2320
        if (in_array($k, ['event','events','calendar','calendar_event','calendar_events'], true)) {
2321
            return 'events';
2322
        }
2323
2324
        // Learnpaths
2325
        if ($k === 'learnpaths') {
2326
            return 'learnpath';
2327
        }
2328
2329
        // Quizzes
2330
        if (in_array($k, ['quiz','quizzes'], true)) {
2331
            return 'quiz';
2332
        }
2333
2334
        // Default: return as-is
2335
        return $k;
2336
    }
2337
2338
    /**
2339
     * Keys to skip as top-level groups in UI.
2340
     *
2341
     * @return array<string,bool>
2342
     */
2343
    private function getSkipTypeKeys(): array
2344
    {
2345
        return [
2346
            'forum_category' => true,
2347
            'forum_topic' => true,
2348
            'forum_post' => true,
2349
            'thread' => true,
2350
            'post' => true,
2351
            'exercise_question' => true,
2352
            'survey_question' => true,
2353
            'survey_invitation' => true,
2354
            'session_course' => true,
2355
            'scorm' => true,
2356
            'asset' => true,
2357
            'link_category' => true,
2358
        ];
2359
    }
2360
2361
    /**
2362
     * Default labels for groups.
2363
     *
2364
     * @return array<string,string>
2365
     */
2366
    private function getDefaultTypeTitles(): array
2367
    {
2368
        return [
2369
            'announcement' => 'announcement',
2370
            'document' => 'Documents',
2371
            'glossary' => 'Glossaries',
2372
            'calendar_event' => 'Calendar events',
2373
            'event' => 'Calendar events',
2374
            'events' => 'Calendar events',
2375
            'link' => 'Links',
2376
            'course_description' => 'Course descriptions',
2377
            'learnpath' => 'Parcours',
2378
            'learnpath_category' => 'Learning path categories',
2379
            'forum' => 'Forums',
2380
            'forum_category' => 'Forum categories',
2381
            'quiz' => 'Exercices',
2382
            'test_category' => 'Test categories',
2383
            'wiki' => 'Wikis',
2384
            'thematic' => 'Thematics',
2385
            'attendance' => 'Attendances',
2386
            'work' => 'Works',
2387
            'session_course' => 'Session courses',
2388
            'gradebook' => 'Gradebook',
2389
            'scorm' => 'SCORM packages',
2390
            'survey' => 'Surveys',
2391
            'survey_question' => 'Survey questions',
2392
            'survey_invitation' => 'Survey invitations',
2393
            'asset' => 'Assets',
2394
            'tool_intro' => 'Tool introductions',
2395
        ];
2396
    }
2397
2398
    /**
2399
     * Decide if an item is selectable (UI).
2400
     */
2401
    private function isSelectableItem(string $type, object $obj): bool
2402
    {
2403
        if ($type === 'announcement') {
2404
            // Require at least a non-empty title
2405
            return isset($obj->title) && trim((string)$obj->title) !== '';
2406
        }
2407
2408
        if ('document' === $type) {
2409
            return true;
2410
        }
2411
2412
        return true;
2413
    }
2414
2415
    /**
2416
     * Resolve label for an item with fallbacks.
2417
     */
2418
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
2419
    {
2420
        if ($type === 'announcement') {
2421
            return (string)($obj->title ?? ("Announcement #".$obj->iid));
2422
        }
2423
2424
        $entity = $this->objectEntity($obj);
2425
2426
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
2427
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
2428
                return trim((string) $entity->{$k});
2429
            }
2430
        }
2431
2432
        if (isset($obj->params) && \is_array($obj->params)) {
2433
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
2434
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
2435
                    return $obj->params[$k];
2436
                }
2437
            }
2438
        }
2439
2440
        switch ($type) {
2441
            case 'events':
2442
                $title = trim((string)($entity->title ?? $entity->name ?? $entity->subject ?? ''));
2443
                if ($title !== '') { return $title; }
2444
2445
                return '#'.$fallbackId;
2446
            case 'document':
2447
                $raw = (string) ($entity->path ?? $obj->path ?? '');
2448
                if ('' !== $raw) {
2449
                    $rel = ltrim($raw, '/');
2450
                    $rel = preg_replace('~^document/?~', '', $rel);
2451
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
2452
                    if ('folder' === $fileType) {
2453
                        $rel = rtrim($rel, '/').'/';
2454
                    }
2455
2456
                    return '' !== $rel ? $rel : basename($raw);
2457
                }
2458
2459
                if (!empty($obj->title)) {
2460
                    return (string) $obj->title;
2461
                }
2462
2463
                break;
2464
2465
            case 'course_description':
2466
                if (!empty($obj->title)) {
2467
                    return (string) $obj->title;
2468
                }
2469
                $t = (int) ($obj->description_type ?? 0);
2470
                $names = [
2471
                    1 => 'Description',
2472
                    2 => 'Objectives',
2473
                    3 => 'Topics',
2474
                    4 => 'Methodology',
2475
                    5 => 'Course material',
2476
                    6 => 'Resources',
2477
                    7 => 'Assessment',
2478
                    8 => 'Custom',
2479
                ];
2480
2481
                return $names[$t] ?? ('#'.$fallbackId);
2482
2483
            case 'announcement':
2484
                if (!empty($obj->title)) {
2485
                    return (string) $obj->title;
2486
                }
2487
2488
                break;
2489
2490
            case 'forum':
2491
                if (!empty($entity->forum_title)) {
2492
                    return (string) $entity->forum_title;
2493
                }
2494
2495
                break;
2496
2497
            case 'forum_category':
2498
                if (!empty($entity->cat_title)) {
2499
                    return (string) $entity->cat_title;
2500
                }
2501
2502
                break;
2503
2504
            case 'link':
2505
                if (!empty($obj->title)) {
2506
                    return (string) $obj->title;
2507
                }
2508
                if (!empty($obj->url)) {
2509
                    return (string) $obj->url;
2510
                }
2511
2512
                break;
2513
2514
            case 'survey':
2515
                if (!empty($obj->title)) {
2516
                    return trim((string) $obj->title);
2517
                }
2518
2519
                break;
2520
2521
            case 'learnpath':
2522
                if (!empty($obj->name)) {
2523
                    return (string) $obj->name;
2524
                }
2525
2526
                break;
2527
2528
            case 'thematic':
2529
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
2530
                    return (string) $obj->params['title'];
2531
                }
2532
2533
                break;
2534
2535
            case 'quiz':
2536
                if (!empty($entity->title)) {
2537
                    return (string) $entity->title;
2538
                }
2539
2540
                break;
2541
2542
            case 'forum_topic':
2543
                if (!empty($entity->thread_title)) {
2544
                    return (string) $entity->thread_title;
2545
                }
2546
2547
                break;
2548
        }
2549
2550
        return '#'.$fallbackId;
2551
    }
2552
2553
    /**
2554
     * Extract wrapped entity (->obj) or the object itself.
2555
     */
2556
    private function objectEntity(object $resource): object
2557
    {
2558
        if (isset($resource->obj) && \is_object($resource->obj)) {
2559
            return $resource->obj;
2560
        }
2561
2562
        return $resource;
2563
    }
2564
2565
    /**
2566
     * Extra payload per item for UI (optional).
2567
     */
2568
    private function buildExtra(string $type, object $obj): array
2569
    {
2570
        $extra = [];
2571
2572
        $get = static function (object $o, string $k, $default = null) {
2573
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
2574
        };
2575
2576
        switch ($type) {
2577
            case 'document':
2578
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
2579
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
2580
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
2581
2582
                break;
2583
2584
            case 'link':
2585
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
2586
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
2587
2588
                break;
2589
2590
            case 'forum':
2591
                $entity = $this->objectEntity($obj);
2592
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
2593
                $extra['default_view'] = (string) ($entity->default_view ?? '');
2594
2595
                break;
2596
2597
            case 'learnpath':
2598
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
2599
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
2600
                    return [
2601
                        'id' => (int) ($i['id'] ?? 0),
2602
                        'title' => (string) ($i['title'] ?? ''),
2603
                        'type' => (string) ($i['item_type'] ?? ''),
2604
                        'path' => (string) ($i['path'] ?? ''),
2605
                    ];
2606
                }, $obj->items) : [];
2607
2608
                break;
2609
2610
            case 'thematic':
2611
                if (isset($obj->params) && \is_array($obj->params)) {
2612
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
2613
                }
2614
2615
                break;
2616
2617
            case 'quiz':
2618
                $entity = $this->objectEntity($obj);
2619
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2620
                    ? array_map('intval', $entity->question_ids)
2621
                    : [];
2622
2623
                break;
2624
2625
            case 'survey':
2626
                $entity = $this->objectEntity($obj);
2627
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2628
                    ? array_map('intval', $entity->question_ids)
2629
                    : [];
2630
2631
                break;
2632
            case 'events':
2633
                $entity = $this->objectEntity($obj);
2634
                $extra['start']    = (string)($entity->start ?? $entity->start_date ?? $entity->timestart ?? '');
2635
                $extra['end']      = (string)($entity->end ?? $entity->end_date ?? $entity->timeend ?? '');
2636
                $extra['all_day']  = (string)($entity->all_day ?? $entity->allday ?? '');
2637
                $extra['location'] = (string)($entity->location ?? '');
2638
                break;
2639
        }
2640
2641
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
2642
    }
2643
2644
    /**
2645
     * Get first existing key from candidates.
2646
     */
2647
    private function firstExistingKey(array $orig, array $candidates): ?string
2648
    {
2649
        foreach ($candidates as $k) {
2650
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
2651
                return $k;
2652
            }
2653
        }
2654
2655
        return null;
2656
    }
2657
2658
    /**
2659
     * Filter legacy Course by UI selections (and pull dependencies).
2660
     *
2661
     * @param array $selected [type => [id => true]]
2662
     */
2663
    private function filterLegacyCourseBySelection(object $course, array $selected): object
2664
    {
2665
        // Sanitize incoming selection (frontend sometimes sends synthetic groups)
2666
        $selected = array_filter($selected, 'is_array');
2667
        unset($selected['undefined']);
2668
2669
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
2670
2671
        if (empty($course->resources) || !\is_array($course->resources)) {
2672
            $this->logDebug('[filterSelection] course has no resources');
2673
2674
            return $course;
2675
        }
2676
2677
        /** @var array<string,mixed> $orig */
2678
        $orig = $course->resources;
2679
2680
        // Preserve meta buckets (keys that start with "__")
2681
        $__metaBuckets = [];
2682
        foreach ($orig as $k => $v) {
2683
            if (\is_string($k) && str_starts_with($k, '__')) {
2684
                $__metaBuckets[$k] = $v;
2685
            }
2686
        }
2687
2688
        $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
2689
2690
        // ---------- Forums flow ----------
2691
        if (!empty($selected['forum'])) {
2692
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
2693
            if (!empty($selForums)) {
2694
                // tolerant lookups
2695
                $forums = $this->findBucket($orig, 'forum');
2696
                $threads = $this->findBucket($orig, 'forum_topic');
2697
                $posts = $this->findBucket($orig, 'forum_post');
2698
2699
                $catsToKeep = [];
2700
2701
                foreach ($forums as $fid => $f) {
2702
                    if (!isset($selForums[(string) $fid])) {
2703
                        continue;
2704
                    }
2705
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
2706
                    $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
2707
                    if ($cid > 0) {
2708
                        $catsToKeep[$cid] = true;
2709
                    }
2710
                }
2711
2712
                $threadToKeep = [];
2713
                foreach ($threads as $tid => $t) {
2714
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
2715
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
2716
                        $threadToKeep[(int) $tid] = true;
2717
                    }
2718
                }
2719
2720
                $postToKeep = [];
2721
                foreach ($posts as $pid => $p) {
2722
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
2723
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
2724
                        $postToKeep[(int) $pid] = true;
2725
                    }
2726
                }
2727
2728
                $out = [];
2729
                foreach ($selected as $type => $ids) {
2730
                    if (!\is_array($ids) || empty($ids)) {
2731
                        continue;
2732
                    }
2733
                    $bucket = $this->findBucket($orig, (string) $type);
2734
                    $key = $this->findBucketKey($orig, (string) $type);
2735
                    if (null !== $key && !empty($bucket)) {
2736
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2737
                        $out[$key] = $this->intersectBucketByIds($bucket, $idsMap);
2738
                    }
2739
                }
2740
2741
                $forumCat = $this->findBucket($orig, 'forum_category');
2742
                $forumBucket = $this->findBucket($orig, 'forum');
2743
                $threadBucket = $this->findBucket($orig, 'forum_topic');
2744
                $postBucket = $this->findBucket($orig, 'forum_post');
2745
2746
                if (!empty($forumCat) && !empty($catsToKeep)) {
2747
                    $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] =
2748
                        array_intersect_key(
2749
                            $forumCat,
2750
                            array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
2751
                        );
2752
                }
2753
2754
                if (!empty($forumBucket)) {
2755
                    $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] =
2756
                        array_intersect_key($forumBucket, $selForums);
2757
                }
2758
                if (!empty($threadBucket)) {
2759
                    $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] =
2760
                        array_intersect_key(
2761
                            $threadBucket,
2762
                            array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
2763
                        );
2764
                }
2765
                if (!empty($postBucket)) {
2766
                    $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] =
2767
                        array_intersect_key(
2768
                            $postBucket,
2769
                            array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
2770
                        );
2771
                }
2772
2773
                // If we have forums but no Forum_Category (edge), keep original categories
2774
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
2775
                    $out['Forum_Category'] = $forumCat;
2776
                }
2777
2778
                $out = array_filter($out);
2779
                $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out;
2780
2781
                $this->logDebug('[filterSelection] end (forums)', [
2782
                    'kept_types' => array_keys($course->resources),
2783
                    'forum_counts' => [
2784
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
2785
                        'forum' => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
2786
                        'thread' => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
2787
                        'post' => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
2788
                    ],
2789
                ]);
2790
2791
                return $course;
2792
            }
2793
        }
2794
2795
        // ---------- Generic + quiz/survey/gradebook ----------
2796
        $keep = [];
2797
        foreach ($selected as $type => $ids) {
2798
            if (!\is_array($ids) || empty($ids)) {
2799
                continue;
2800
            }
2801
            $legacyKey = $this->findBucketKey($orig, (string) $type);
2802
            if (null === $legacyKey) {
2803
                continue;
2804
            }
2805
            $bucket = $orig[$legacyKey] ?? [];
2806
            if (!empty($bucket) && \is_array($bucket)) {
2807
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2808
                $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap);
2809
            }
2810
        }
2811
2812
        // Gradebook
2813
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
2814
        if ($gbKey && !empty($selected['gradebook'])) {
2815
            $gbBucket = $getBucket($orig, $gbKey);
2816
            if (!empty($gbBucket)) {
2817
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
2818
                $firstItem = reset($gbBucket);
2819
2820
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
2821
                    $keep[$gbKey] = $gbBucket;
2822
                    $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]);
2823
                } else {
2824
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
2825
                    $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
2826
                }
2827
            }
2828
        }
2829
2830
        // Quizzes -> questions (+ images)
2831
        $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']);
2832
        if ($quizKey && !empty($keep[$quizKey])) {
2833
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
2834
            if ($questionKey) {
2835
                $qids = [];
2836
                foreach ($keep[$quizKey] as $qid => $qwrap) {
2837
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2838
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
2839
                        foreach ($q->question_ids as $sid) {
2840
                            $qids[(string) $sid] = true;
2841
                        }
2842
                    }
2843
                }
2844
                if (!empty($qids)) {
2845
                    $questionBucket = $getBucket($orig, $questionKey);
2846
                    $selQ = array_intersect_key($questionBucket, $qids);
2847
                    if (!empty($selQ)) {
2848
                        $keep[$questionKey] = $selQ;
2849
2850
                        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2851
                        if ($docKey) {
2852
                            $docBucket = $getBucket($orig, $docKey);
2853
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
2854
                            if (!empty($imageQuizBucket)) {
2855
                                $needed = [];
2856
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
2857
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2858
                                    $pic = (string) ($q->picture ?? '');
2859
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
2860
                                        $needed[$pic] = true;
2861
                                    }
2862
                                }
2863
                                if (!empty($needed)) {
2864
                                    $keep[$docKey] = $keep[$docKey] ?? [];
2865
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
2866
                                }
2867
                            }
2868
                        }
2869
                    }
2870
                }
2871
            } else {
2872
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found');
2873
            }
2874
        }
2875
2876
        // Surveys -> questions (+ invitations)
2877
        $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']);
2878
        if ($surveyKey && !empty($keep[$surveyKey])) {
2879
            $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
2880
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
2881
2882
            if ($surveyQuestionKey) {
2883
                $neededQids = [];
2884
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
2885
2886
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
2887
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
2888
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
2889
                        foreach ($s->question_ids as $qid) {
2890
                            $neededQids[(string) $qid] = true;
2891
                        }
2892
                    }
2893
                }
2894
                if (empty($neededQids)) {
2895
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2896
                    foreach ($surveyQBucket as $qid => $qWrap) {
2897
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2898
                        $qSurveyId = (string) ($q->survey_id ?? '');
2899
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2900
                            $neededQids[(string) $qid] = true;
2901
                        }
2902
                    }
2903
                }
2904
                if (!empty($neededQids)) {
2905
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2906
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2907
                }
2908
            } else {
2909
                $this->logDebug('[filterSelection] surveys selected but no question bucket found');
2910
            }
2911
2912
            if ($surveyInvitationKey) {
2913
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2914
                if (!empty($invBucket)) {
2915
                    $neededInv = [];
2916
                    foreach ($invBucket as $iid => $invWrap) {
2917
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2918
                        $sid = (string) ($inv->survey_id ?? '');
2919
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2920
                            $neededInv[(string) $iid] = true;
2921
                        }
2922
                    }
2923
                    if (!empty($neededInv)) {
2924
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2925
                    }
2926
                }
2927
            }
2928
        }
2929
2930
        // Documents: add parent folders for selected files
2931
        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2932
        if ($docKey && !empty($keep[$docKey])) {
2933
            $docBucket = $getBucket($orig, $docKey);
2934
2935
            $foldersByRel = [];
2936
            foreach ($docBucket as $fid => $res) {
2937
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2938
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2939
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2940
                if (!$isFolder) {
2941
                    continue;
2942
                }
2943
2944
                $p = (string) ($e->path ?? '');
2945
                if ('' === $p) {
2946
                    continue;
2947
                }
2948
2949
                $frel = '/'.ltrim(substr($p, 8), '/');
2950
                $frel = rtrim($frel, '/').'/';
2951
                if ('//' !== $frel) {
2952
                    $foldersByRel[$frel] = $fid;
2953
                }
2954
            }
2955
2956
            $needFolderIds = [];
2957
            foreach ($keep[$docKey] as $id => $res) {
2958
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2959
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2960
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2961
                if ($isFolder) {
2962
                    continue;
2963
                }
2964
2965
                $p = (string) ($e->path ?? '');
2966
                if ('' === $p) {
2967
                    continue;
2968
                }
2969
2970
                $rel = '/'.ltrim(substr($p, 8), '/');
2971
                $dir = rtrim(\dirname($rel), '/');
2972
                if ('' === $dir) {
2973
                    continue;
2974
                }
2975
2976
                $acc = '';
2977
                foreach (array_filter(explode('/', $dir)) as $seg) {
2978
                    $acc .= '/'.$seg;
2979
                    $accKey = rtrim($acc, '/').'/';
2980
                    if (isset($foldersByRel[$accKey])) {
2981
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2982
                    }
2983
                }
2984
            }
2985
            if (!empty($needFolderIds)) {
2986
                $added = array_intersect_key($docBucket, $needFolderIds);
2987
                $keep[$docKey] += $added;
2988
            }
2989
        }
2990
2991
        // Links -> pull categories used by the selected links
2992
        $lnkKey = $this->firstExistingKey($orig, ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']);
2993
        if ($lnkKey && !empty($keep[$lnkKey])) {
2994
            $catIdsUsed = [];
2995
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2996
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2997
                $cid = (int) ($L->category_id ?? 0);
2998
                if ($cid > 0) {
2999
                    $catIdsUsed[(string) $cid] = true;
3000
                }
3001
            }
3002
3003
            $catKey = $this->firstExistingKey($orig, ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']);
3004
            if ($catKey && !empty($catIdsUsed)) {
3005
                $catBucket = $getBucket($orig, $catKey);
3006
                if (!empty($catBucket)) {
3007
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
3008
                    $keep[$catKey] = $subset;
3009
                    $keep['link_category'] = $subset; // mirror for convenience
3010
                }
3011
            }
3012
        }
3013
3014
        $keep = array_filter($keep);
3015
        $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep;
3016
3017
        $this->logDebug('[filterSelection] non-forum flow end', [
3018
            'selected_types' => array_keys($selected),
3019
            'orig_types' => array_keys($orig),
3020
            'kept_types' => array_keys($course->resources ?? []),
3021
        ]);
3022
3023
        return $course;
3024
    }
3025
3026
    /**
3027
     * Map UI options (1/2/3) to legacy file policy.
3028
     */
3029
    private function mapSameNameOption(int $opt): int
3030
    {
3031
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
3032
3033
        if (!\defined('FILE_SKIP')) {
3034
            \define('FILE_SKIP', 1);
3035
        }
3036
        if (!\defined('FILE_RENAME')) {
3037
            \define('FILE_RENAME', 2);
3038
        }
3039
        if (!\defined('FILE_OVERWRITE')) {
3040
            \define('FILE_OVERWRITE', 3);
3041
        }
3042
3043
        return match ($opt) {
3044
            1 => FILE_SKIP,
3045
            3 => FILE_OVERWRITE,
3046
            default => FILE_RENAME,
3047
        };
3048
    }
3049
3050
    /**
3051
     * Set debug mode from Request (query/header).
3052
     */
3053
    private function setDebugFromRequest(?Request $req): void
3054
    {
3055
        if (!$req) {
3056
            return;
3057
        }
3058
        // Query param wins
3059
        if ($req->query->has('debug')) {
3060
            $this->debug = $req->query->getBoolean('debug');
3061
3062
            return;
3063
        }
3064
        // Fallback to header
3065
        $hdr = $req->headers->get('X-Debug');
3066
        if (null !== $hdr) {
3067
            $val = trim((string) $hdr);
3068
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
3069
        }
3070
    }
3071
3072
    /**
3073
     * Debug logger with stage + compact JSON payload.
3074
     */
3075
    private function logDebug(string $stage, mixed $payload = null): void
3076
    {
3077
        if (!$this->debug) {
3078
            return;
3079
        }
3080
        $prefix = 'COURSE_DEBUG';
3081
        if (null === $payload) {
3082
            error_log("$prefix: $stage");
3083
3084
            return;
3085
        }
3086
        // Safe/short json
3087
        $json = null;
3088
3089
        try {
3090
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
3091
            if (null !== $json && \strlen($json) > 8000) {
3092
                $json = substr($json, 0, 8000).'…(truncated)';
3093
            }
3094
        } catch (Throwable $e) {
3095
            $json = '[payload_json_error: '.$e->getMessage().']';
3096
        }
3097
        error_log("$prefix: $stage -> $json");
3098
    }
3099
3100
    /**
3101
     * Snapshot of resources bag for quick inspection.
3102
     */
3103
    private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array
3104
    {
3105
        $out = [];
3106
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3107
        $i = 0;
3108
        foreach ($res as $type => $bag) {
3109
            if ($i++ >= $maxTypes) {
3110
                $out['__notice'] = 'types truncated';
3111
3112
                break;
3113
            }
3114
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
3115
            if (\is_array($bag)) {
3116
                $j = 0;
3117
                foreach ($bag as $id => $obj) {
3118
                    if ($j++ >= $maxItemsPerType) {
3119
                        $snap['sample'][] = ['__notice' => 'truncated'];
3120
3121
                        break;
3122
                    }
3123
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3124
                    $snap['sample'][] = [
3125
                        'id' => (string) $id,
3126
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
3127
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
3128
                    ];
3129
                }
3130
            }
3131
            $out[(string) $type] = $snap;
3132
        }
3133
3134
        return $out;
3135
    }
3136
3137
    /**
3138
     * Snapshot of forum-family counters.
3139
     */
3140
    private function snapshotForumCounts(object $course): array
3141
    {
3142
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
3143
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
3144
3145
        return [
3146
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
3147
            'forum' => $get('forum', 'Forum'),
3148
            'thread' => $get('thread', 'forum_topic'),
3149
            'post' => $get('post', 'forum_post'),
3150
        ];
3151
    }
3152
3153
    /**
3154
     * Builds the selection map [type => [id => true]] from high-level types.
3155
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
3156
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
3157
     *
3158
     * @param object   $course        Legacy Course with already hydrated resources
3159
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
3160
     *
3161
     * @return array<string, array<int|string, bool>>
3162
     */
3163
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
3164
    {
3165
        $selectedTypes = array_map(
3166
            fn ($t) => $this->normalizeTypeKey((string) $t),
3167
            $selectedTypes
3168
        );
3169
3170
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3171
3172
        $coreDeps = [
3173
            'document', 'link', 'quiz', 'work', 'survey',
3174
            'Forum_Category', 'forum', 'thread', 'post',
3175
            'exercise_question', 'survey_question', 'link_category',
3176
        ];
3177
3178
        $presentKeys = array_fill_keys(array_map(
3179
            fn ($k) => $this->normalizeTypeKey((string) $k),
3180
            array_keys($res)
3181
        ), true);
3182
3183
        $out = [];
3184
3185
        $addBucket = function (string $typeKey) use (&$out, $res): void {
3186
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
3187
                return;
3188
            }
3189
            $ids = [];
3190
            foreach ($res[$typeKey] as $id => $_) {
3191
                $ids[(string) $id] = true;
3192
            }
3193
            if ($ids) {
3194
                $out[$typeKey] = $ids;
3195
            }
3196
        };
3197
3198
        foreach ($selectedTypes as $t) {
3199
            $addBucket($t);
3200
3201
            if ('learnpath' === $t) {
3202
                foreach ($coreDeps as $depRaw) {
3203
                    $dep = $this->normalizeTypeKey($depRaw);
3204
                    if (isset($presentKeys[$dep])) {
3205
                        $addBucket($dep);
3206
                    }
3207
                }
3208
            }
3209
        }
3210
3211
        $this->logDebug('[buildSelectionFromTypes] built', [
3212
            'selectedTypes' => $selectedTypes,
3213
            'kept_types' => array_keys($out),
3214
        ]);
3215
3216
        return $out;
3217
    }
3218
3219
    /**
3220
     * Build link tree (Category → Link) for the UI.
3221
     * Categories are not selectable; links are leaves (item_count = 0).
3222
     */
3223
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
3224
    {
3225
        $this->logDebug('[buildLinkTreeForVue] start');
3226
3227
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3228
        $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? [];
3229
        $linkRaw = $res['link'] ?? $res['Link'] ?? [];
3230
3231
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
3232
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
3233
            'links' => \is_array($linkRaw) ? \count($linkRaw) : 0,
3234
        ]);
3235
3236
        $cats = [];
3237
        foreach ($catRaw as $id => $obj) {
3238
            $id = (int) $id;
3239
            if ($id <= 0 || !\is_object($obj)) {
3240
                continue;
3241
            }
3242
            $e = $this->objectEntity($obj);
3243
            $label = $this->resolveItemLabel('link_category', $e, $id);
3244
            $cats[$id] = [
3245
                'id' => $id,
3246
                'type' => 'link_category',
3247
                'label' => (('' !== $label ? $label : ('Category #'.$id)).'/'),
3248
                'selectable' => true,
3249
                'items' => [],
3250
                'has_children' => false,
3251
                'item_count' => 0,
3252
                'extra' => ['filetype' => 'folder'],
3253
            ];
3254
        }
3255
3256
        // Virtual "Uncategorized"
3257
        $uncatKey = -9999;
3258
        if (!isset($cats[$uncatKey])) {
3259
            $cats[$uncatKey] = [
3260
                'id' => $uncatKey,
3261
                'type' => 'link_category',
3262
                'label' => 'Uncategorized/',
3263
                'selectable' => true,
3264
                'items' => [],
3265
                '_virtual' => true,
3266
                'has_children' => false,
3267
                'item_count' => 0,
3268
                'extra' => ['filetype' => 'folder'],
3269
            ];
3270
        }
3271
3272
        // Assign links to categories
3273
        foreach ($linkRaw as $id => $obj) {
3274
            $id = (int) $id;
3275
            if ($id <= 0 || !\is_object($obj)) {
3276
                continue;
3277
            }
3278
            $e = $this->objectEntity($obj);
3279
3280
            $cid = (int) ($e->category_id ?? 0);
3281
            if (!isset($cats[$cid])) {
3282
                $cid = $uncatKey;
3283
            }
3284
3285
            $cats[$cid]['items'][] = [
3286
                'id' => $id,
3287
                'type' => 'link',
3288
                'label' => $this->resolveItemLabel('link', $e, $id),
3289
                'extra' => $this->buildExtra('link', $e) ?: new stdClass(),
3290
                'selectable' => true,
3291
                'item_count' => 0,
3292
            ];
3293
        }
3294
3295
        // Drop empty virtual category, sort, and finalize UI hints
3296
        $catNodes = array_values(array_filter($cats, static function ($c) {
3297
            if (!empty($c['_virtual']) && empty($c['items'])) {
3298
                return false;
3299
            }
3300
3301
            return true;
3302
        }));
3303
3304
        foreach ($catNodes as &$c) {
3305
            if (!empty($c['items'])) {
3306
                usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3307
            }
3308
            $c['has_children'] = !empty($c['items']);
3309
            $c['item_count'] = \count($c['items'] ?? []);
3310
        }
3311
        unset($c);
3312
3313
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3314
3315
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
3316
3317
        return [
3318
            'type' => 'link',
3319
            'title' => $groupTitle,
3320
            'items' => $catNodes,
3321
        ];
3322
    }
3323
3324
    /**
3325
     * Leaves only the items selected by the UI in $course->resources.
3326
     * Expects $selected with the following form:
3327
     * [
3328
     * "documents" => ["123" => true, "124" => true],
3329
     * "links" => ["7" => true],
3330
     * "quiz" => ["45" => true],
3331
     * ...
3332
     * ].
3333
     */
3334
    private function filterCourseResources(object $course, array $selected): void
3335
    {
3336
        if (!isset($course->resources) || !\is_array($course->resources)) {
3337
            return;
3338
        }
3339
3340
        $typeMap = [
3341
            'documents' => RESOURCE_DOCUMENT,
3342
            'links' => RESOURCE_LINK,
3343
            'quizzes' => RESOURCE_QUIZ,
3344
            'quiz' => RESOURCE_QUIZ,
3345
            'quiz_questions' => RESOURCE_QUIZQUESTION,
3346
            'surveys' => RESOURCE_SURVEY,
3347
            'survey' => RESOURCE_SURVEY,
3348
            'survey_questions' => RESOURCE_SURVEYQUESTION,
3349
            'announcement' => RESOURCE_ANNOUNCEMENT,
3350
            'events' => RESOURCE_EVENT,
3351
            'course_description' => RESOURCE_COURSEDESCRIPTION,
3352
            'glossary' => RESOURCE_GLOSSARY,
3353
            'wiki' => RESOURCE_WIKI,
3354
            'thematic' => RESOURCE_THEMATIC,
3355
            'attendance' => RESOURCE_ATTENDANCE,
3356
            'works' => RESOURCE_WORK,
3357
            'gradebook' => RESOURCE_GRADEBOOK,
3358
            'learnpaths' => RESOURCE_LEARNPATH,
3359
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
3360
            'tool_intro' => RESOURCE_TOOL_INTRO,
3361
            'forums' => RESOURCE_FORUM,
3362
            'forum' => RESOURCE_FORUM,
3363
            'forum_topic' => RESOURCE_FORUMTOPIC,
3364
            'forum_post' => RESOURCE_FORUMPOST,
3365
        ];
3366
3367
        $allowed = [];
3368
        foreach ($selected as $k => $idsMap) {
3369
            $key = $typeMap[$k] ?? $k;
3370
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
3371
        }
3372
3373
        foreach ($course->resources as $rtype => $bucket) {
3374
            if (!isset($allowed[$rtype])) {
3375
                continue;
3376
            }
3377
            $keep = $allowed[$rtype];
3378
            $filtered = [];
3379
            foreach ((array) $bucket as $id => $obj) {
3380
                $iid = (int) ($obj->source_id ?? $id);
3381
                if (isset($keep[$iid])) {
3382
                    $filtered[$id] = $obj;
3383
                }
3384
            }
3385
            $course->resources[$rtype] = $filtered;
3386
        }
3387
    }
3388
3389
    /**
3390
     * Resolve absolute path of a backupId inside the backups directory, with safety checks.
3391
     */
3392
    private function resolveBackupPath(string $backupId): string
3393
    {
3394
        $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR);
3395
        $baseReal = realpath($base) ?: $base;
3396
3397
        $file = basename($backupId);
3398
        $path = $baseReal.DIRECTORY_SEPARATOR.$file;
3399
3400
        $real = realpath($path);
3401
3402
        if (false !== $real && 0 === strncmp($real, $baseReal, \strlen($baseReal))) {
3403
            return $real;
3404
        }
3405
3406
        return $path;
3407
    }
3408
3409
    /**
3410
     * Load a legacy Course object from any backup:
3411
     * - Chamilo (.zip with course_info.dat) → CourseArchiver::readCourse() or lenient fallback (your original logic)
3412
     * - Moodle (.mbz/.tgz/.gz or ZIP with moodle_backup.xml) → MoodleImport builder
3413
     *
3414
     * IMPORTANT:
3415
     * - Keeps your original Chamilo flow intact (strict → fallback manual decode/unserialize).
3416
     * - Tries Moodle only when the package looks like Moodle.
3417
     * - Adds __meta.import_source = "chamilo" | "moodle" for downstream logic.
3418
     */
3419
    private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object
3420
    {
3421
        $path = $this->resolveBackupPath($backupId);
3422
3423
        $force = strtolower($force);
3424
        if ('dat' === $force || 'chamilo' === $force) {
3425
            $looksMoodle = false;
3426
            $preferChamilo = true;
3427
        } elseif ('moodle' === $force) {
3428
            $looksMoodle = true;
3429
            $preferChamilo = false;
3430
        } else {
3431
            $looksMoodle = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path);
3432
            $preferChamilo = $this->zipHasCourseInfoDat($path);
3433
        }
3434
3435
        if ($preferChamilo || !$looksMoodle) {
3436
            CourseArchiver::setDebug($this->debug);
3437
3438
            try {
3439
                $course = CourseArchiver::readCourse($backupId, false);
3440
                if (\is_object($course)) {
3441
                    // … (resto igual)
3442
                    if (!isset($course->resources) || !\is_array($course->resources)) {
3443
                        $course->resources = [];
3444
                    }
3445
                    $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3446
                    $course->resources['__meta']['import_source'] = 'chamilo';
3447
3448
                    return $course;
3449
                }
3450
            } catch (Throwable $e) {
3451
                $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]);
3452
            }
3453
3454
            $zipPath = $this->resolveBackupPath($backupId);
3455
            $ci = $this->readCourseInfoFromZip($zipPath);
3456
            if (empty($ci['ok'])) {
3457
                if ($looksMoodle) {
3458
                    $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort');
3459
3460
                    return $this->loadMoodleCourseOrFail($path);
3461
                }
3462
3463
                throw new RuntimeException('course_info.dat not found in backup');
3464
            }
3465
3466
            $raw = (string) $ci['data'];
3467
            $payload = base64_decode($raw, true);
3468
            if (false === $payload) {
3469
                $payload = $raw;
3470
            }
3471
3472
            $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload);
3473
            CourseArchiver::ensureLegacyAliases();
3474
3475
            set_error_handler(static function (): void {});
3476
3477
            try {
3478
                if (class_exists(UnserializeApi::class)) {
3479
                    $c = UnserializeApi::unserialize('course', $payload);
3480
                } else {
3481
                    $c = @unserialize($payload, ['allowed_classes' => true]);
3482
                }
3483
            } finally {
3484
                restore_error_handler();
3485
            }
3486
3487
            if (!\is_object($c ?? null)) {
3488
                if ($looksMoodle) {
3489
                    $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo fallback failed, trying MoodleImport');
3490
3491
                    return $this->loadMoodleCourseOrFail($path);
3492
                }
3493
3494
                throw new RuntimeException('Could not unserialize course (fallback)');
3495
            }
3496
3497
            if (!isset($c->resources) || !\is_array($c->resources)) {
3498
                $c->resources = [];
3499
            }
3500
            $c->resources['__meta'] = (array) ($c->resources['__meta'] ?? []);
3501
            $c->resources['__meta']['import_source'] = 'chamilo';
3502
3503
            return $c;
3504
        }
3505
3506
        // Moodle path
3507
        if ($looksMoodle) {
3508
            $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
3509
3510
            return $this->loadMoodleCourseOrFail($path);
3511
        }
3512
3513
        throw new RuntimeException('Unsupported package: neither course_info.dat nor moodle_backup.xml found.');
3514
    }
3515
3516
    /**
3517
     * Normalize resource buckets to the exact keys supported by CourseRestorer.
3518
     * Only the canonical keys below are produced; common aliases are mapped.
3519
     * - Never drop data: merge buckets; keep __meta as-is.
3520
     * - Make sure "document" survives if it existed before.
3521
     */
3522
    private function normalizeBucketsForRestorer(object $course): void
3523
    {
3524
        if (!isset($course->resources) || !\is_array($course->resources)) {
3525
            return;
3526
        }
3527
3528
        // Split meta buckets
3529
        $all = $course->resources;
3530
        $meta = [];
3531
        foreach ($all as $k => $v) {
3532
            if (\is_string($k) && str_starts_with($k, '__')) {
3533
                $meta[$k] = $v;
3534
                unset($all[$k]);
3535
            }
3536
        }
3537
3538
        // Start from current
3539
        $out = $all;
3540
3541
        // merge array buckets preserving numeric/string ids
3542
        $merge = static function (array $dst, array $src): array {
3543
            foreach ($src as $id => $obj) {
3544
                if (!\array_key_exists($id, $dst)) {
3545
                    $dst[$id] = $obj;
3546
                }
3547
            }
3548
3549
            return $dst;
3550
        };
3551
3552
        // safe alias map (input -> canonical). Extend only if needed.
3553
        $aliases = [
3554
            // documents
3555
            'documents' => 'document',
3556
            'Document' => 'document',
3557
            'document ' => 'document',
3558
3559
            // tool intro
3560
            'tool introduction' => 'tool_intro',
3561
            'tool_introduction' => 'tool_intro',
3562
            'tool/introduction' => 'tool_intro',
3563
            'tool intro' => 'tool_intro',
3564
            'Tool introduction' => 'tool_intro',
3565
3566
            // forums
3567
            'forums' => 'forum',
3568
            'Forum' => 'forum',
3569
            'Forum_Category' => 'forum_category',
3570
            'forumcategory' => 'forum_category',
3571
            'thread' => 'forum_topic',
3572
            'Thread' => 'forum_topic',
3573
            'forumtopic' => 'forum_topic',
3574
            'post' => 'forum_post',
3575
            'Post' => 'forum_post',
3576
            'forumpost' => 'forum_post',
3577
3578
            // links
3579
            'links' => 'link',
3580
            'link category' => 'link_category',
3581
3582
            // quiz + questions
3583
            'Exercise_Question' => 'exercise_question',
3584
            'exercisequestion' => 'exercise_question',
3585
3586
            // surveys
3587
            'surveys' => 'survey',
3588
            'surveyquestion' => 'survey_question',
3589
3590
            // announcements
3591
            'announcements' => 'announcement',
3592
            'Announcements' => 'announcement',
3593
        ];
3594
3595
        // Normalize keys (case/spacing) and apply alias merges
3596
        foreach ($all as $rawKey => $_bucket) {
3597
            if (!\is_array($_bucket)) {
3598
                continue; // defensive
3599
            }
3600
            $k = (string) $rawKey;
3601
            $norm = strtolower(trim(strtr($k, ['\\' => '/', '-' => '_'])));
3602
            $norm2 = str_replace('/', '_', $norm);
3603
3604
            $canonical = null;
3605
            if (isset($aliases[$norm])) {
3606
                $canonical = $aliases[$norm];
3607
            } elseif (isset($aliases[$norm2])) {
3608
                $canonical = $aliases[$norm2];
3609
            }
3610
3611
            if ($canonical && $canonical !== $rawKey) {
3612
                // Merge into canonical and drop the alias key
3613
                $out[$canonical] = isset($out[$canonical]) && \is_array($out[$canonical])
3614
                    ? $merge($out[$canonical], $_bucket)
3615
                    : $_bucket;
3616
                unset($out[$rawKey]);
3617
            }
3618
            // else: leave as-is (pass-through)
3619
        }
3620
3621
        // Safety: if there was any docs bucket under an alias, ensure 'document' is present.
3622
        if (!isset($out['document'])) {
3623
            if (isset($all['documents']) && \is_array($all['documents'])) {
3624
                $out['document'] = $all['documents'];
3625
            } elseif (isset($all['Document']) && \is_array($all['Document'])) {
3626
                $out['document'] = $all['Document'];
3627
            }
3628
        }
3629
3630
        // Gentle ordering for readability only (does not affect presence)
3631
        $order = [
3632
            'announcement', 'document', 'link', 'link_category',
3633
            'forum', 'forum_category', 'forum_topic', 'forum_post',
3634
            'quiz', 'exercise_question',
3635
            'survey', 'survey_question',
3636
            'learnpath', 'tool_intro',
3637
            'work',
3638
        ];
3639
        $w = [];
3640
        foreach ($order as $i => $key) {
3641
            $w[$key] = $i;
3642
        }
3643
        uksort($out, static function ($a, $b) use ($w) {
3644
            $wa = $w[$a] ?? 9999;
3645
            $wb = $w[$b] ?? 9999;
3646
3647
            return $wa <=> $wb ?: strcasecmp((string) $a, (string) $b);
3648
        });
3649
3650
        // Final assign: meta first, then normalized buckets
3651
        $course->resources = $meta + $out;
3652
3653
        // Debug trace to verify we didn't lose keys
3654
        $this->logDebug('[normalizeBucketsForRestorer] final keys', array_keys((array) $course->resources));
3655
    }
3656
3657
    /**
3658
     * Read import_source without depending on filtered resources.
3659
     * Falls back to $course->info['__import_source'] if needed.
3660
     */
3661
    private function getImportSource(object $course): string
3662
    {
3663
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
3664
        if ('' !== $src) {
3665
            return $src;
3666
        }
3667
3668
        // Fallbacks (defensive)
3669
        return strtolower((string) ($course->info['__import_source'] ?? ''));
3670
    }
3671
3672
    /**
3673
     * Builds a CC 1.3 preview from the legacy Course (only the implemented one).
3674
     * Returns a structure intended for rendering/committing before the actual export.
3675
     */
3676
    private function buildCc13Preview(object $course): array
3677
    {
3678
        $ims = [
3679
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document']
3680
            'resources' => [
3681
                'webcontent' => [],
3682
            ],
3683
            'counts' => ['files' => 0, 'folders' => 0],
3684
            'defaultSelection' => [
3685
                'documents' => [],
3686
            ],
3687
        ];
3688
3689
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3690
        $docKey = null;
3691
3692
        foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) {
3693
            if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) {
3694
                $docKey = $cand;
3695
3696
                break;
3697
            }
3698
        }
3699
        if (!$docKey) {
3700
            return $ims;
3701
        }
3702
3703
        foreach ($res[$docKey] as $iid => $wrap) {
3704
            if (!\is_object($wrap)) {
3705
                continue;
3706
            }
3707
            $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3708
3709
            $rawPath = (string) ($e->path ?? $e->full_path ?? '');
3710
            if ('' === $rawPath) {
3711
                continue;
3712
            }
3713
            $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/');
3714
3715
            $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
3716
            $isDir = ('folder' === $fileType) || ('/' === substr($rawPath, -1));
3717
3718
            $title = (string) ($e->title ?? $wrap->name ?? basename($rel));
3719
            $ims['resources']['webcontent'][] = [
3720
                'id' => (int) $iid,
3721
                'cc_type' => 'webcontent',
3722
                'title' => '' !== $title ? $title : basename($rel),
3723
                'rel' => $rel,
3724
                'is_dir' => $isDir,
3725
                'would_be_manifest_entry' => !$isDir,
3726
            ];
3727
3728
            if (!$isDir) {
3729
                $ims['defaultSelection']['documents'][(int) $iid] = true;
3730
                $ims['counts']['files']++;
3731
            } else {
3732
                $ims['counts']['folders']++;
3733
            }
3734
        }
3735
3736
        return $ims;
3737
    }
3738
3739
    /**
3740
     * Expand category selections (link/forum) to their item IDs using a full course snapshot.
3741
     * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel.
3742
     *
3743
     * @return array<string, array<string, bool>
3744
     */
3745
    private function expandCc13SelectionFromCategories(object $course, array $normSel): array
3746
    {
3747
        $out = [
3748
            'documents' => (array) ($normSel['documents'] ?? []),
3749
            'links' => (array) ($normSel['links'] ?? []),
3750
            'forums' => (array) ($normSel['forums'] ?? []),
3751
        ];
3752
3753
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3754
3755
        // Link categories → link IDs
3756
        if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) {
3757
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true);
3758
            $links = $res['link'] ?? $res['Link'];
3759
            foreach ($links as $lid => $wrap) {
3760
                if (!\is_object($wrap)) {
3761
                    continue;
3762
                }
3763
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3764
                $cid = (string) (int) ($e->category_id ?? 0);
3765
                if (isset($selCats[$cid])) {
3766
                    $out['links'][(string) $lid] = true;
3767
                }
3768
            }
3769
        }
3770
3771
        // Forum categories → forum IDs
3772
        if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) {
3773
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true);
3774
            $forums = $res['forum'] ?? $res['Forum'];
3775
            foreach ($forums as $fid => $wrap) {
3776
                if (!\is_object($wrap)) {
3777
                    continue;
3778
                }
3779
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3780
                $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
3781
                if (isset($selCats[$cid])) {
3782
                    $out['forums'][(string) $fid] = true;
3783
                }
3784
            }
3785
        }
3786
3787
        return $out;
3788
    }
3789
3790
    /**
3791
     * Infer tool buckets required by a given selection payload (used in 'selected' scope).
3792
     *
3793
     * Expected selection items like: { "type": "document"|"quiz"|"survey"|... , "id": <int> }
3794
     *
3795
     * @param array<int,array<string,mixed>> $selected
3796
     *
3797
     * @return string[]
3798
     */
3799
    private function inferToolsFromSelection(array $selected): array
3800
    {
3801
        $has = static fn (string $k): bool => !empty($selected[$k]) && \is_array($selected[$k]) && \count($selected[$k]) > 0;
3802
3803
        $want = [];
3804
3805
        // documents
3806
        if ($has('document')) {
3807
            $want[] = 'documents';
3808
        }
3809
3810
        // links (categories imply links too)
3811
        if ($has('link') || $has('link_category')) {
3812
            $want[] = 'links';
3813
        }
3814
3815
        // forums (any of the family implies forums)
3816
        if ($has('forum') || $has('forum_category') || $has('forum_topic') || $has('thread') || $has('post') || $has('forum_post')) {
3817
            $want[] = 'forums';
3818
        }
3819
3820
        // quizzes / questions
3821
        if ($has('quiz') || $has('exercise') || $has('exercise_question')) {
3822
            $want[] = 'quizzes';
3823
            $want[] = 'quiz_questions';
3824
        }
3825
3826
        // surveys / questions / invitations
3827
        if ($has('survey') || $has('survey_question') || $has('survey_invitation')) {
3828
            $want[] = 'surveys';
3829
            $want[] = 'survey_questions';
3830
        }
3831
3832
        // learnpaths
3833
        if ($has('learnpath') || $has('learnpath_category')) {
3834
            $want[] = 'learnpaths';
3835
            $want[] = 'learnpath_category';
3836
        }
3837
3838
        // others
3839
        if ($has('work'))     { $want[] = 'works'; }
3840
        if ($has('glossary')) { $want[] = 'glossary'; }
3841
        if ($has('tool_intro')) { $want[] = 'tool_intro'; }
3842
        if ($has('attendance')) { $want[] = 'attendance'; }
3843
        if ($has('announcement')) { $want[] = 'announcement'; }
3844
        if ($has('calendar_event')) { $want[] = 'events'; }
3845
        if ($has('wiki')) { $want[] = 'wiki'; }
3846
        if ($has('thematic')) { $want[] = 'thematic'; }
3847
        if ($has('gradebook')) { $want[] = 'gradebook'; }
3848
3849
        if ($has('course_descriptions') || $has('course_description')) { $tools[] = 'course_descriptions'; }
3850
        if ($has('work')) {
3851
            $want[] = 'works';
3852
        }
3853
        if ($has('glossary')) {
3854
            $want[] = 'glossary';
3855
        }
3856
        if ($has('tool_intro')) {
3857
            $want[] = 'tool_intro';
3858
        }
3859
        if ($has('course_descriptions') || $has('course_description')) {
3860
            $tools[] = 'course_descriptions';
3861
        }
3862
3863
        // Dedup
3864
        return array_values(array_unique(array_filter($want)));
3865
    }
3866
3867
    private function intersectBucketByIds(array $bucket, array $idsMap): array
3868
    {
3869
        $out = [];
3870
        foreach ($bucket as $id => $obj) {
3871
            $ent = (isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3872
            $k1 = (string) $id;
3873
            $k2 = (string) ($ent->source_id ?? $obj->source_id ?? '');
3874
            if (isset($idsMap[$k1]) || ('' !== $k2 && isset($idsMap[$k2]))) {
3875
                $out[$id] = $obj;
3876
            }
3877
        }
3878
3879
        return $out;
3880
    }
3881
3882
    private function bucketKeyCandidates(string $type): array
3883
    {
3884
        $t = $this->normalizeTypeKey($type);
3885
3886
        // Constants (string values) if defined
3887
        $RD = \defined('RESOURCE_DOCUMENT') ? (string) RESOURCE_DOCUMENT : '';
3888
        $RL = \defined('RESOURCE_LINK') ? (string) RESOURCE_LINK : '';
3889
        $RF = \defined('RESOURCE_FORUM') ? (string) RESOURCE_FORUM : '';
3890
        $RFT = \defined('RESOURCE_FORUMTOPIC') ? (string) RESOURCE_FORUMTOPIC : '';
3891
        $RFP = \defined('RESOURCE_FORUMPOST') ? (string) RESOURCE_FORUMPOST : '';
3892
        $RQ = \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : '';
3893
        $RQQ = \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : '';
3894
        $RS = \defined('RESOURCE_SURVEY') ? (string) RESOURCE_SURVEY : '';
3895
        $RSQ = \defined('RESOURCE_SURVEYQUESTION') ? (string) RESOURCE_SURVEYQUESTION : '';
3896
3897
        $map = [
3898
            'document' => ['document', 'Document', $RD],
3899
            'link' => ['link', 'Link', $RL],
3900
            'link_category' => ['link_category', 'Link_Category'],
3901
            'forum' => ['forum', 'Forum', $RF],
3902
            'forum_category' => ['forum_category', 'Forum_Category'],
3903
            'forum_topic' => ['forum_topic', 'thread', $RFT],
3904
            'forum_post' => ['forum_post', 'post', $RFP],
3905
            'quiz' => ['quiz', 'Quiz', $RQ],
3906
            'exercise_question' => ['Exercise_Question', 'exercise_question', $RQQ],
3907
            'survey' => ['survey', 'Survey', $RS],
3908
            'survey_question' => ['Survey_Question', 'survey_question', $RSQ],
3909
            'tool_intro' => ['tool_intro', 'Tool introduction'],
3910
        ];
3911
3912
        $c = $map[$t] ?? [$t, ucfirst($t)];
3913
3914
        return array_values(array_filter($c, static fn ($x) => '' !== $x));
3915
    }
3916
3917
    private function findBucketKey(array $res, string $type): ?string
3918
    {
3919
        $key = $this->firstExistingKey($res, $this->bucketKeyCandidates($type));
3920
3921
        return null !== $key ? (string) $key : null;
3922
    }
3923
3924
    private function findBucket(array $res, string $type): array
3925
    {
3926
        $k = $this->findBucketKey($res, $type);
3927
3928
        return (null !== $k && isset($res[$k]) && \is_array($res[$k])) ? $res[$k] : [];
3929
    }
3930
3931
    /**
3932
     * True if file extension suggests a Moodle backup.
3933
     */
3934
    private function isMoodleByExt(string $path): bool
3935
    {
3936
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3937
3938
        return \in_array($ext, ['mbz', 'tgz', 'gz'], true);
3939
    }
3940
3941
    /**
3942
     * Quick ZIP probe for 'moodle_backup.xml'. Safe no-op for non-zip files.
3943
     */
3944
    private function zipHasMoodleBackupXml(string $path): bool
3945
    {
3946
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3947
        // Many .mbz are plain ZIPs; try to open if extension is zip/mbz
3948
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3949
            return false;
3950
        }
3951
        $zip = new ZipArchive();
3952
        if (true !== ($err = $zip->open($path))) {
3953
            return false;
3954
        }
3955
        $idx = $zip->locateName('moodle_backup.xml', ZipArchive::FL_NOCASE);
3956
        $zip->close();
3957
3958
        return false !== $idx;
3959
    }
3960
3961
    /**
3962
     * Quick ZIP probe for 'course_info.dat'. Safe no-op for non-zip files.
3963
     */
3964
    private function zipHasCourseInfoDat(string $path): bool
3965
    {
3966
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3967
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3968
            return false;
3969
        }
3970
        $zip = new ZipArchive();
3971
        if (true !== ($err = $zip->open($path))) {
3972
            return false;
3973
        }
3974
        // common locations
3975
        foreach (['course_info.dat', 'course/course_info.dat', 'backup/course_info.dat'] as $cand) {
3976
            $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
3977
            if (false !== $idx) {
3978
                $zip->close();
3979
3980
                return true;
3981
            }
3982
        }
3983
        $zip->close();
3984
3985
        return false;
3986
    }
3987
3988
    /**
3989
     * Build legacy Course graph from a Moodle archive and set __meta.import_source.
3990
     * Throws RuntimeException on failure.
3991
     */
3992
    private function loadMoodleCourseOrFail(string $absPath): object
3993
    {
3994
        if (!class_exists(MoodleImport::class)) {
3995
            throw new RuntimeException('MoodleImport class not available');
3996
        }
3997
        $importer = new MoodleImport(debug: $this->debug);
3998
3999
        if (!method_exists($importer, 'buildLegacyCourseFromMoodleArchive')) {
4000
            throw new RuntimeException('MoodleImport::buildLegacyCourseFromMoodleArchive() not available');
4001
        }
4002
4003
        $course = $importer->buildLegacyCourseFromMoodleArchive($absPath);
4004
4005
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
4006
            throw new RuntimeException('Moodle backup contains no importable resources');
4007
        }
4008
4009
        $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
4010
        $course->resources['__meta']['import_source'] = 'moodle';
4011
4012
        return $course;
4013
    }
4014
4015
    /**
4016
     * Clone a course snapshot keeping only the allowed buckets (preserving original-case keys).
4017
     * This is a shallow clone of the course object with a filtered 'resources' array.
4018
     */
4019
    private function cloneCourseWithBuckets(object $course, array $allowed): object
4020
    {
4021
        $clone = clone $course;
4022
4023
        if (!isset($course->resources) || !\is_array($course->resources)) {
4024
            $clone->resources = [];
4025
            return $clone;
4026
        }
4027
4028
        // Build a lookup based on the original-case keys present in $allowed
4029
        $allowedLookup = array_flip($allowed);
4030
4031
        // Intersect by original-case keys
4032
        $clone->resources = array_intersect_key($course->resources, $allowedLookup);
4033
4034
        return $clone;
4035
    }
4036
4037
    /**
4038
     * Generic runner for a bucket group: checks if it was requested, extracts present snapshot keys,
4039
     * appends legacy constant keys (if defined), and delegates to MoodleImport::restoreSelectedBuckets().
4040
     *
4041
     * Returns:
4042
     *   - array stats when executed,
4043
     *   - ['imported'=>0,'notes'=>['No ... buckets']] when requested but none present,
4044
     *   - null when not requested at all.
4045
     */
4046
    private function runBucketRestore(
4047
        MoodleImport           $importer,
4048
        array                  $requestedNormalized,
4049
        array                  $requestAliases,
4050
        array                  $snapshotAliases,
4051
        array                  $legacyConstNames,
4052
        object                 $course,
4053
        string                 $backupPath,
4054
        EntityManagerInterface $em,
4055
        int                    $cid,
4056
        int                    $sid,
4057
        int                    $sameFileNameOption,
4058
        string $statKey
4059
    ): ?array {
4060
        // Normalize request aliases and check if intersect
4061
        $norm = static fn(string $k): string => strtolower((string) $k);
4062
        $reqSet = array_map($norm, $requestAliases);
4063
4064
        if (count(array_intersect($requestedNormalized, $reqSet)) === 0) {
4065
            // Not requested -> skip quietly
4066
            return null;
4067
        }
4068
4069
        // Gather snapshot-present keys
4070
        $resources = (array) ($course->resources ?? []);
4071
        $present = array_keys($resources);
4072
        $presentNorm = array_map($norm, $present);
4073
4074
        $snapWanted = array_map($norm, $snapshotAliases);
4075
        $allowed = [];
4076
        foreach ($present as $idx => $origKey) {
4077
            if (in_array($presentNorm[$idx], $snapWanted, true)) {
4078
                $allowed[] = $origKey; // keep original key casing as in snapshot
4079
            }
4080
4081
            return $out;
4082
        }
4083
4084
        // Add legacy constant-based keys if defined
4085
        foreach ($legacyConstNames as $c) {
4086
            if (\defined($c)) {
4087
                $allowed[] = (string) \constant($c);
4088
            }
4089
4090
            return (object) $clean;
4091
        }
4092
4093
        // Deduplicate + sanitize
4094
        $allowed = array_values(array_unique(array_filter($allowed, static fn($v) => \is_string($v) && $v !== '')));
4095
4096
        // Quick clone of the course with only these buckets
4097
        $courseForThis = $this->cloneCourseWithBuckets($course, $allowed);
4098
4099
        if (empty((array) ($courseForThis->resources ?? []))) {
4100
            $this->logDebug("[runBucketRestore] {$statKey} skipped (no matching buckets present)", [
4101
                'requested' => $requestAliases,
4102
                'snapshot_aliases' => $snapshotAliases,
4103
                'legacy' => $legacyConstNames,
4104
                'available' => array_keys((array) $course->resources),
4105
            ]);
4106
            return ['imported' => 0, 'notes' => ["No {$statKey} buckets"]];
4107
        }
4108
4109
        if (\method_exists($importer, 'attachContext')) {
4110
            // Optional internal context
4111
            $importer->attachContext($backupPath, $em, $cid, $sid, $sameFileNameOption);
4112
        }
4113
4114
        // Delegate with stable signature
4115
        return $importer->restoreSelectedBuckets(
4116
            $backupPath,
4117
            $em,
4118
            $cid,
4119
            $sid,
4120
            $allowed,
4121
            $courseForThis
4122
        );
4123
    }
4124
}
4125