Passed
Pull Request — master (#6894)
by
unknown
12:02 queued 03:47
created

CourseMaintenanceController::buildLinkTreeForVue()   D

Complexity

Conditions 18
Paths 192

Size

Total Lines 92
Code Lines 57

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use Chamilo\CourseBundle\Component\CourseCopy\Course;
10
use Chamilo\CourseBundle\Component\CourseCopy\CourseArchiver;
11
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
12
use Chamilo\CourseBundle\Component\CourseCopy\CourseRecycler;
13
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
14
use Chamilo\CourseBundle\Component\CourseCopy\CourseSelectForm;
15
use CourseManager;
16
use Doctrine\ORM\EntityManagerInterface;
17
use stdClass;
18
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
19
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
20
use Symfony\Component\Routing\Attribute\Route;
21
use Throwable;
22
23
use const ARRAY_FILTER_USE_BOTH;
24
use const JSON_PRETTY_PRINT;
25
use const JSON_UNESCAPED_SLASHES;
26
use const JSON_UNESCAPED_UNICODE;
27
use const PATHINFO_EXTENSION;
28
29
#[Route(
30
    '/course_maintenance/{node}',
31
    name: 'cm_',
32
    requirements: ['node' => '\d+']
33
)]
34
class CourseMaintenanceController extends AbstractController
35
{
36
    /**
37
     * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1
38
     */
39
    private bool $debug = true;
40
41
    #[Route('/import/options', name: 'import_options', methods: ['GET'])]
42
    public function importOptions(int $node, Request $req): JsonResponse
43
    {
44
        $this->setDebugFromRequest($req);
45
        $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]);
46
47
        return $this->json([
48
            'sources' => ['local', 'server'],
49
            'importOptions' => ['full_backup', 'select_items'],
50
            'sameName' => ['skip', 'rename', 'overwrite'],
51
            'defaults' => [
52
                'importOption' => 'full_backup',
53
                'sameName' => 'rename',
54
                'sameFileNameOption' => 2,
55
            ],
56
        ]);
57
    }
58
59
    #[Route('/import/upload', name: 'import_upload', methods: ['POST'])]
60
    public function importUpload(int $node, Request $req): JsonResponse
61
    {
62
        $this->setDebugFromRequest($req);
63
        $file = $req->files->get('file');
64
        if (!$file) {
65
            $this->logDebug('[importUpload] missing file');
66
67
            return $this->json(['error' => 'Missing file'], 400);
68
        }
69
70
        $this->logDebug('[importUpload] received', [
71
            'original_name' => $file->getClientOriginalName(),
72
            'size' => $file->getSize(),
73
            'mime' => $file->getClientMimeType(),
74
        ]);
75
76
        $backupId = CourseArchiver::importUploadedFile($file->getRealPath());
77
        if (false === $backupId) {
78
            $this->logDebug('[importUpload] archive dir not writable');
79
80
            return $this->json(['error' => 'Archive directory is not writable'], 500);
81
        }
82
83
        $this->logDebug('[importUpload] stored', ['backupId' => $backupId]);
84
85
        return $this->json([
86
            'backupId' => $backupId,
87
            'filename' => $file->getClientOriginalName(),
88
        ]);
89
    }
90
91
    #[Route('/import/server', name: 'import_server_pick', methods: ['POST'])]
92
    public function importServerPick(int $node, Request $req): JsonResponse
93
    {
94
        $this->setDebugFromRequest($req);
95
        $payload = json_decode($req->getContent() ?: '{}', true);
96
        $filename = $payload['filename'] ?? null;
97
        if (!$filename) {
98
            $this->logDebug('[importServerPick] missing filename');
99
100
            return $this->json(['error' => 'Missing filename'], 400);
101
        }
102
103
        $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename;
104
        if (!is_file($path)) {
105
            $this->logDebug('[importServerPick] file not found', ['path' => $path]);
106
107
            return $this->json(['error' => 'File not found'], 404);
108
        }
109
110
        $this->logDebug('[importServerPick] ok', ['backupId' => $filename]);
111
112
        return $this->json(['backupId' => $filename, 'filename' => $filename]);
113
    }
114
115
    #[Route(
116
        '/import/{backupId}/resources',
117
        name: 'import_resources',
118
        requirements: ['backupId' => '.+'],
119
        methods: ['GET']
120
    )]
121
    public function importResources(int $node, string $backupId, Request $req): JsonResponse
122
    {
123
        $this->setDebugFromRequest($req);
124
        $this->logDebug('[importResources] begin', ['node' => $node, 'backupId' => $backupId]);
125
126
        try {
127
            /** @var Course $course */
128
            $course = CourseArchiver::readCourse($backupId, false);
129
130
            $this->logDebug('[importResources] course loaded', [
131
                'has_resources' => \is_array($course->resources ?? null),
132
                'keys' => array_keys((array) ($course->resources ?? [])),
133
            ]);
134
            $this->logDebug('[importResources] resources snapshot', $this->snapshotResources($course));
135
            $this->logDebug('[importResources] forum counts', $this->snapshotForumCounts($course));
136
137
            $tree = $this->buildResourceTreeForVue($course);
138
            $this->logDebug(
139
                '[importResources] UI tree groups',
140
                array_map(fn ($g) => ['type' => $g['type'], 'title' => $g['title'], 'items' => \count($g['items'] ?? [])], $tree)
141
            );
142
143
            if ($this->debug && $req->query->getBoolean('debug')) {
144
                $base = $this->getParameter('kernel.project_dir').'/var/log/course_backup_debug';
145
                @mkdir($base, 0775, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

145
                /** @scrutinizer ignore-unhandled */ @mkdir($base, 0775, true);

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
146
                @file_put_contents(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

146
                /** @scrutinizer ignore-unhandled */ @file_put_contents(

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
147
                    $base.'/'.preg_replace('/[^a-zA-Z0-9._-]/', '_', $backupId).'.json',
148
                    json_encode([
149
                        'tree' => $tree,
150
                        'resources_keys' => array_keys((array) ($course->resources ?? [])),
151
                    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
152
                );
153
                $this->logDebug('[importResources] wrote debug snapshot to var/log/course_backup_debug');
154
            }
155
156
            $warnings = [];
157
            if (empty($tree)) {
158
                $warnings[] = 'Backup has no selectable resources.';
159
            }
160
161
            return $this->json([
162
                'tree' => $tree,
163
                'warnings' => $warnings,
164
            ]);
165
        } catch (Throwable $e) {
166
            $this->logDebug('[importResources] exception', ['message' => $e->getMessage()]);
167
168
            return $this->json([
169
                'tree' => [],
170
                'warnings' => ['Error reading backup: '.$e->getMessage()],
171
            ], 200);
172
        }
173
    }
174
175
    #[Route(
176
        '/import/{backupId}/restore',
177
        name: 'import_restore',
178
        requirements: ['backupId' => '.+'],
179
        methods: ['POST']
180
    )]
181
    public function importRestore(int $node, string $backupId, Request $req): JsonResponse
182
    {
183
        $this->setDebugFromRequest($req);
184
        $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]);
185
186
        try {
187
            // Read payload
188
            $payload = json_decode($req->getContent() ?: '{}', true);
189
            $importOption = (string) ($payload['importOption'] ?? 'full_backup');
190
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
191
192
            /** @var array<string,array> $selectedResources */
193
            $selectedResources = (array) ($payload['resources'] ?? []);
194
195
            /** @var string[] $selectedTypes */
196
            $selectedTypes = array_map('strval', (array) ($payload['selectedTypes'] ?? []));
197
198
            $this->logDebug('[importRestore] input', [
199
                'importOption' => $importOption,
200
                'sameFileNameOption' => $sameFileNameOption,
201
                'selectedTypes' => $selectedTypes,
202
                'hasResourcesMap' => !empty($selectedResources),
203
            ]);
204
205
            // Resolve file path
206
            $backupDir = CourseArchiver::getBackupDir();
207
            $this->logDebug('[importRestore] backup dir', $backupDir);
208
            $path = rtrim($backupDir, '/').'/'.$backupId;
209
            $this->logDebug('[importRestore] path exists?', [
210
                'path' => $path,
211
                'exists' => is_file($path),
212
                'readable' => is_readable($path),
213
            ]);
214
215
            // Load legacy Course
216
            /** @var Course $course */
217
            $course = CourseArchiver::readCourse($backupId, false);
218
219
            if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
220
                $this->logDebug('[importRestore] course empty resources');
221
222
                return $this->json(['error' => 'Backup has no resources'], 400);
223
            }
224
225
            // Snapshots before filtering (debug)
226
            $this->logDebug('[importRestore] BEFORE filter keys', array_keys($course->resources));
227
            $this->logDebug('[importRestore] BEFORE forum counts', $this->snapshotForumCounts($course));
228
            $this->logDebug('[importRestore] BEFORE resources snapshot', $this->snapshotResources($course));
229
230
            $resourcesAll = (array) ($course->resources ?? []);
231
            $this->logDebug('[importRestore] resources_all snapshot captured', ['keys' => array_keys($resourcesAll)]);
232
233
            // Partial selection logic
234
            if ('select_items' === $importOption) {
235
                $this->hydrateLpDependenciesFromSnapshot($course, $resourcesAll ?? []);
236
237
                // If the UI sent only high-level types (e.g., ["learnpath"]) and no item map,
238
                // build a resources selection map from those types.
239
                if (empty($selectedResources) && !empty($selectedTypes)) {
240
                    if (method_exists($this, 'buildSelectionFromTypes')) {
241
                        $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes);
242
                    }
243
                    $this->logDebug('[importRestore] built selection from types', [
244
                        'selectedTypes' => $selectedTypes,
245
                        'built_keys' => array_keys($selectedResources),
246
                    ]);
247
                }
248
249
                // Validate selection map
250
                $hasAny = false;
251
                foreach ($selectedResources as $t => $ids) {
252
                    if (\is_array($ids) && !empty($ids)) {
253
                        $hasAny = true;
254
255
                        break;
256
                    }
257
                }
258
                if (!$hasAny) {
259
                    $this->logDebug('[importRestore] empty selection');
260
261
                    return $this->json(['error' => 'No resources selected'], 400);
262
                }
263
264
                // Filter legacy course by selection (keeps only selected buckets/ids).
265
                // Dependency pulling for LP/quizzes/surveys/etc. should be handled inside the Restorer,
266
                // using the full snapshot we pass below (no dynamic properties on $course).
267
                $course = $this->filterLegacyCourseBySelection($course, $selectedResources);
268
269
                if (empty($course->resources) || 0 === \count((array) $course->resources)) {
270
                    $this->logDebug('[importRestore] selection produced no resources');
271
272
                    return $this->json(['error' => 'Selection produced no resources to restore'], 400);
273
                }
274
            }
275
276
            // Snapshots after filtering (debug)
277
            $this->logDebug('[importRestore] AFTER filter keys', array_keys($course->resources));
278
            $this->logDebug('[importRestore] AFTER forum counts', $this->snapshotForumCounts($course));
279
            $this->logDebug('[importRestore] AFTER resources snapshot', $this->snapshotResources($course));
280
281
            // Restore
282
            $restorer = new CourseRestorer($course);
283
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
284
285
            if (method_exists($restorer, 'setResourcesAllSnapshot')) {
286
                $restorer->setResourcesAllSnapshot($resourcesAll);
287
                $this->logDebug('[importRestore] restorer snapshot forwarded', ['keys' => array_keys($resourcesAll)]);
288
            }
289
            if (method_exists($restorer, 'setDebug')) {
290
                $restorer->setDebug($this->debug);
291
            }
292
293
            $restorer->restore();
294
295
            $this->logDebug('[importRestore] restore() finished', [
296
                'dest_course_id' => $restorer->destination_course_info['real_id'] ?? null,
297
            ]);
298
299
            // Cleanup temporary backup dir
300
            CourseArchiver::cleanBackupDir();
301
302
            // Redirect info
303
            $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0);
304
            $sessionId = 0;
305
            $groupId = 0;
306
            $redirectUrl = \sprintf('/course/%d/home?sid=%d&gid=%d', $courseId, $sessionId, $groupId);
307
308
            $this->logDebug('[importRestore] done, redirect', ['url' => $redirectUrl]);
309
310
            return $this->json([
311
                'ok' => true,
312
                'message' => 'Import finished',
313
                'redirectUrl' => $redirectUrl,
314
            ]);
315
        } catch (Throwable $e) {
316
            $this->logDebug('[importRestore] exception', [
317
                'message' => $e->getMessage(),
318
                'file' => $e->getFile().':'.$e->getLine(),
319
            ]);
320
321
            return $this->json([
322
                'error' => 'Restore failed: '.$e->getMessage(),
323
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
324
            ], 500);
325
        }
326
    }
327
328
    #[Route('/copy/options', name: 'copy_options', methods: ['GET'])]
329
    public function copyOptions(int $node, Request $req): JsonResponse
330
    {
331
        $this->setDebugFromRequest($req);
332
333
        $current = api_get_course_info();
334
        $courseList = CourseManager::getCoursesFollowedByUser(api_get_user_id());
335
336
        $courses = [];
337
        foreach ($courseList as $c) {
338
            if ((int) $c['real_id'] === (int) $current['real_id']) {
339
                continue;
340
            }
341
            $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']];
342
        }
343
344
        return $this->json([
345
            'courses' => $courses,
346
            'defaults' => [
347
                'copyOption' => 'full_copy',
348
                'includeUsers' => false,
349
                'resetDates' => true,
350
                'sameFileNameOption' => 2,
351
            ],
352
        ]);
353
    }
354
355
    #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])]
356
    public function copyResources(int $node, Request $req): JsonResponse
357
    {
358
        $this->setDebugFromRequest($req);
359
        $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', ''));
360
        if ('' === $sourceCourseCode) {
361
            return $this->json(['error' => 'Missing sourceCourseId'], 400);
362
        }
363
364
        $cb = new CourseBuilder();
365
        $cb->set_tools_to_build([
366
            'documents',
367
            'forums',
368
            'tool_intro',
369
            'links',
370
            'quizzes',
371
            'quiz_questions',
372
            'assets',
373
            'surveys',
374
            'survey_questions',
375
            'announcements',
376
            'events',
377
            'course_descriptions',
378
            'glossary',
379
            'wiki',
380
            'thematic',
381
            'attendance',
382
            'works',
383
            'gradebook',
384
            'learnpath_category',
385
            'learnpaths',
386
        ]);
387
388
        $course = $cb->build(0, $sourceCourseCode);
389
390
        $tree = $this->buildResourceTreeForVue($course);
391
392
        $warnings = [];
393
        if (empty($tree)) {
394
            $warnings[] = 'Source course has no resources.';
395
        }
396
397
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
398
    }
399
400
    #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])]
401
    public function copyExecute(int $node, Request $req): JsonResponse
402
    {
403
        $this->setDebugFromRequest($req);
404
405
        try {
406
            $payload = json_decode($req->getContent() ?: '{}', true);
407
408
            $sourceCourseId = (string) ($payload['sourceCourseId'] ?? '');
409
            $copyOption = (string) ($payload['copyOption'] ?? 'full_copy');
410
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
411
            $selectedResourcesMap = (array) ($payload['resources'] ?? []);
412
413
            if ('' === $sourceCourseId) {
414
                return $this->json(['error' => 'Missing sourceCourseId'], 400);
415
            }
416
417
            $cb = new CourseBuilder('partial');
418
            $cb->set_tools_to_build([
419
                'documents',
420
                'forums',
421
                'tool_intro',
422
                'links',
423
                'quizzes',
424
                'quiz_questions',
425
                'assets',
426
                'surveys',
427
                'survey_questions',
428
                'announcements',
429
                'events',
430
                'course_descriptions',
431
                'glossary',
432
                'wiki',
433
                'thematic',
434
                'attendance',
435
                'works',
436
                'gradebook',
437
                'learnpath_category',
438
                'learnpaths',
439
            ]);
440
            $legacyCourse = $cb->build(0, $sourceCourseId);
441
442
            if ('select_items' === $copyOption) {
443
                $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap);
444
445
                if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) {
446
                    return $this->json(['error' => 'Selection produced no resources to copy'], 400);
447
                }
448
            }
449
450
            error_log('$legacyCourse :::: '.print_r($legacyCourse, true));
0 ignored issues
show
Bug introduced by
Are you sure print_r($legacyCourse, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

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