Passed
Push — master ( 9df631...0f56b8 )
by
unknown
10:44
created

buildForumTreeForVue()   F

Complexity

Conditions 35
Paths > 20000

Size

Total Lines 154
Code Lines 103

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 35
eloc 103
nc 136080
nop 2
dl 0
loc 154
rs 0
c 1
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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

131
                /** @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...
132
                @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

132
                /** @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...
133
                    $base.'/'.preg_replace('/[^a-zA-Z0-9._-]/', '_', $backupId).'.json',
134
                    json_encode([
135
                        'tree'           => $tree,
136
                        'resources_keys' => array_keys((array) ($course->resources ?? [])),
137
                    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
138
                );
139
                $this->logDebug('[importResources] wrote debug snapshot to var/log/course_backup_debug');
140
            }
141
142
            $warnings = [];
143
            if (empty($tree)) {
144
                $warnings[] = 'Backup has no selectable resources.';
145
            }
146
147
            return $this->json([
148
                'tree'     => $tree,
149
                'warnings' => $warnings,
150
            ]);
151
        } catch (\Throwable $e) {
152
            $this->logDebug('[importResources] exception', ['message' => $e->getMessage()]);
153
            return $this->json([
154
                'tree'     => [],
155
                'warnings' => ['Error reading backup: '.$e->getMessage()],
156
            ], 200);
157
        }
158
    }
159
160
    #[Route(
161
        '/import/{backupId}/restore',
162
        name: 'import_restore',
163
        requirements: ['backupId' => '.+'],
164
        methods: ['POST']
165
    )]
166
    public function importRestore(int $node, string $backupId, Request $req): JsonResponse
167
    {
168
        $this->setDebugFromRequest($req);
169
        $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]);
170
171
        try {
172
            $payload = json_decode($req->getContent() ?: "{}", true);
173
            $importOption       = (string) ($payload['importOption'] ?? 'full_backup');
174
            $sameFileNameOption = (int)    ($payload['sameFileNameOption'] ?? 2);
175
            $selectedResources  = (array)  ($payload['resources'] ?? []);
176
177
            $this->logDebug('[importRestore] input', [
178
                'importOption'       => $importOption,
179
                'sameFileNameOption' => $sameFileNameOption,
180
                'selectedTypes'      => array_keys($selectedResources),
181
            ]);
182
183
            $backupDir = CourseArchiver::getBackupDir();
184
            $this->logDebug('[importRestore] backup dir', $backupDir);
185
            $path = rtrim($backupDir, '/').'/'.$backupId;
186
            $this->logDebug('[importRestore] path exists?', [
187
                'path'     => $path,
188
                'exists'   => is_file($path),
189
                'readable' => is_readable($path),
190
            ]);
191
192
            /** @var Course $course */
193
            $course = CourseArchiver::readCourse($backupId, false);
194
195
            if (!is_object($course) || empty($course->resources) || !is_array($course->resources)) {
196
                $this->logDebug('[importRestore] course empty resources');
197
                return $this->json(['error' => 'Backup has no resources'], 400);
198
            }
199
200
            $this->logDebug('[importRestore] BEFORE filter keys', array_keys($course->resources));
201
            $this->logDebug('[importRestore] BEFORE forum counts', $this->snapshotForumCounts($course));
202
203
            if ($importOption === 'select_items') {
204
                $hasAny = false;
205
                foreach ($selectedResources as $t => $ids) {
206
                    if (is_array($ids) && !empty($ids)) {
207
                        $hasAny = true;
208
                        break;
209
                    }
210
                }
211
                if (!$hasAny) {
212
                    $this->logDebug('[importRestore] empty selection');
213
                    return $this->json(['error' => 'No resources selected'], 400);
214
                }
215
216
                $course = $this->filterLegacyCourseBySelection($course, $selectedResources);
217
218
                if (empty($course->resources) || count((array) $course->resources) === 0) {
219
                    $this->logDebug('[importRestore] selection produced no resources');
220
                    return $this->json(['error' => 'Selection produced no resources to restore'], 400);
221
                }
222
            }
223
224
            $this->logDebug('[importRestore] AFTER filter keys', array_keys($course->resources));
225
            $this->logDebug('[importRestore] AFTER forum counts', $this->snapshotForumCounts($course));
226
            $this->logDebug('[importRestore] AFTER resources snapshot', $this->snapshotResources($course));
227
228
            $restorer = new CourseRestorer($course);
229
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
230
            if (method_exists($restorer, 'setDebug')) {
231
                $restorer->setDebug($this->debug);
232
                $this->logDebug('[importRestore] restorer debug forwarded', ['debug' => $this->debug]);
233
            }
234
235
            $this->logDebug('[importRestore] calling restore()');
236
            $restorer->restore();
237
            $this->logDebug('[importRestore] restore() finished', [
238
                'dest_course_id' => $restorer->destination_course_info['real_id'] ?? null,
239
            ]);
240
241
            CourseArchiver::cleanBackupDir();
242
243
            $courseId    = (int) ($restorer->destination_course_info['real_id'] ?? 0);
244
            $sessionId   = 0;
245
            $groupId     = 0;
246
            $redirectUrl = sprintf('/course/%d/home?sid=%d&gid=%d', $courseId, $sessionId, $groupId);
247
248
            $this->logDebug('[importRestore] done, redirect', ['url' => $redirectUrl]);
249
250
            return $this->json([
251
                'ok'          => true,
252
                'message'     => 'Import finished',
253
                'redirectUrl' => $redirectUrl,
254
            ]);
255
        } catch (\Throwable $e) {
256
            $this->logDebug('[importRestore] exception', [
257
                'message' => $e->getMessage(),
258
                'file'    => $e->getFile().':'.$e->getLine(),
259
            ]);
260
            return $this->json([
261
                'error'   => 'Restore failed: '.$e->getMessage(),
262
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
263
            ], 500);
264
        }
265
    }
266
267
    #[Route('/copy/options', name: 'copy_options', methods: ['GET'])]
268
    public function copyOptions(int $node, Request $req): JsonResponse
269
    {
270
        $this->setDebugFromRequest($req);
271
272
        $current = api_get_course_info();
273
        $courseList = CourseManager::getCoursesFollowedByUser(
274
            api_get_user_id(),
275
            COURSEMANAGER,
276
            null, null, null, null, false, null, null, false,
277
            'ORDER BY c.title'
278
        );
279
280
        $courses = [];
281
        foreach ($courseList as $c) {
282
            if ((int) $c['real_id'] === (int) $current['real_id']) {
283
                continue;
284
            }
285
            $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']];
286
        }
287
288
        return $this->json([
289
            'courses'  => $courses,
290
            'defaults' => [
291
                'copyOption'         => 'full_copy',
292
                'includeUsers'       => false,
293
                'resetDates'         => true,
294
                'sameFileNameOption' => 2,
295
            ],
296
        ]);
297
    }
298
299
    #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])]
300
    public function copyResources(int $node, Request $req): JsonResponse
301
    {
302
        $this->setDebugFromRequest($req);
303
        $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', ''));
304
        if ($sourceCourseCode === '') {
305
            return $this->json(['error' => 'Missing sourceCourseId'], 400);
306
        }
307
308
        $cb = new CourseBuilder();
309
        $cb->set_tools_to_build([
310
            'documents',
311
            'forums',
312
            'tool_intro',
313
            'links',
314
            'quizzes',
315
            'quiz_questions',
316
            'assets',
317
            'surveys',
318
            'survey_questions',
319
            'announcements',
320
            'events',
321
            'course_descriptions',
322
            'glossary',
323
            'wiki',
324
            'thematic',
325
            'attendance',
326
            'works',
327
            'gradebook',
328
            'learnpath_category',
329
            'learnpaths',
330
        ]);
331
332
        $course = $cb->build(0, $sourceCourseCode);
333
334
        $tree = $this->buildResourceTreeForVue($course);
335
336
        $warnings = [];
337
        if (empty($tree)) {
338
            $warnings[] = 'Source course has no resources.';
339
        }
340
341
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
342
    }
343
344
    #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])]
345
    public function copyExecute(int $node, Request $req): JsonResponse
346
    {
347
        $this->setDebugFromRequest($req);
348
349
        try {
350
            $payload = json_decode($req->getContent() ?: "{}", true);
351
352
            $sourceCourseId       = (string) ($payload['sourceCourseId'] ?? '');
353
            $copyOption           = (string) ($payload['copyOption'] ?? 'full_copy');
354
            $sameFileNameOption   = (int)    ($payload['sameFileNameOption'] ?? 2);
355
            $selectedResourcesMap = (array)  ($payload['resources'] ?? []);
356
357
            if ($sourceCourseId === '') {
358
                return $this->json(['error' => 'Missing sourceCourseId'], 400);
359
            }
360
361
            $cb = new CourseBuilder('partial');
362
            $cb->set_tools_to_build([
363
                'documents',
364
                'forums',
365
                'tool_intro',
366
                'links',
367
                'quizzes',
368
                'quiz_questions',
369
                'assets',
370
                'surveys',
371
                'survey_questions',
372
                'announcements',
373
                'events',
374
                'course_descriptions',
375
                'glossary',
376
                'wiki',
377
                'thematic',
378
                'attendance',
379
                'works',
380
                'gradebook',
381
                'learnpath_category',
382
                'learnpaths',
383
            ]);
384
            $legacyCourse = $cb->build(0, $sourceCourseId);
385
386
            if ($copyOption === 'select_items') {
387
                $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap);
388
389
                if (empty($legacyCourse->resources) || !is_array($legacyCourse->resources)) {
390
                    return $this->json(['error' => 'Selection produced no resources to copy'], 400);
391
                }
392
            }
393
394
            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

394
            error_log('$legacyCourse :::: './** @scrutinizer ignore-type */ print_r($legacyCourse, true));
Loading history...
395
396
            $restorer = new CourseRestorer($legacyCourse);
397
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
398
            if (method_exists($restorer, 'setDebug')) {
399
                $restorer->setDebug($this->debug);
400
            }
401
            $restorer->restore();
402
403
            $dest = api_get_course_info();
404
            $redirectUrl = sprintf('/course/%d/home', (int) $dest['real_id']);
405
406
            return $this->json([
407
                'ok'          => true,
408
                'message'     => 'Copy finished',
409
                'redirectUrl' => $redirectUrl,
410
            ]);
411
        } catch (\Throwable $e) {
412
            return $this->json([
413
                'error'   => 'Copy failed: '.$e->getMessage(),
414
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
415
            ], 500);
416
        }
417
    }
418
419
    #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])]
420
    public function recycleOptions(int $node, Request $req): JsonResponse
421
    {
422
        $this->setDebugFromRequest($req);
423
424
        // current course only
425
        $defaults = [
426
            'recycleOption' => 'select_items', // 'full_recycle' | 'select_items'
427
            'confirmNeeded' => true,           // show code-confirm input when full
428
        ];
429
430
        return $this->json(['defaults' => $defaults]);
431
    }
432
433
    #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])]
434
    public function recycleResources(int $node, Request $req): JsonResponse
435
    {
436
        $this->setDebugFromRequest($req);
437
438
        // Build legacy Course from CURRENT course (not “source”)
439
        $cb = new CourseBuilder();
440
        $cb->set_tools_to_build([
441
            'documents','forums','tool_intro','links','quizzes','quiz_questions','assets','surveys',
442
            'survey_questions','announcements','events','course_descriptions','glossary','wiki',
443
            'thematic','attendance','works','gradebook','learnpath_category','learnpaths',
444
        ]);
445
        $course = $cb->build(0, api_get_course_id());
446
447
        $tree = $this->buildResourceTreeForVue($course);
448
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
449
450
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
451
    }
452
453
    #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])]
454
    public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse
455
    {
456
        try {
457
            $p = json_decode($req->getContent() ?: "{}", true);
458
            $recycleOption = (string)($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items'
459
            $resourcesMap  = (array) ($p['resources'] ?? []);
460
            $confirmCode   = (string)($p['confirm'] ?? '');
461
462
            $type = $recycleOption === 'full_recycle' ? 'full_backup' : 'select_items';
463
464
            if ($type === 'full_backup') {
465
                if ($confirmCode !== api_get_course_id()) {
466
                    return $this->json(['error' => 'Course code confirmation mismatch'], 400);
467
                }
468
            } else {
469
                if (empty($resourcesMap)) {
470
                    return $this->json(['error' => 'No resources selected'], 400);
471
                }
472
            }
473
474
            $courseCode = api_get_course_id();
475
            $courseInfo = api_get_course_info($courseCode);
476
            $courseId   = (int)($courseInfo['real_id'] ?? 0);
477
            if ($courseId <= 0) {
478
                return $this->json(['error' => 'Invalid course id'], 400);
479
            }
480
481
            $recycler = new CourseRecycler(
482
                $em,
483
                $courseCode,
484
                $courseId
485
            );
486
487
            $recycler->recycle($type, $resourcesMap);
488
489
            return $this->json([
490
                'ok'      => true,
491
                'message' => 'Recycle finished',
492
            ]);
493
        } catch (\Throwable $e) {
494
            return $this->json([
495
                'error'   => 'Recycle failed: '.$e->getMessage(),
496
            ], 500);
497
        }
498
    }
499
500
    #[Route('/delete', name: 'delete', methods: ['POST'])]
501
    public function deleteCourse(int $node, Request $req): JsonResponse
502
    {
503
        // Basic permission gate (adjust roles to your policy if needed)
504
        if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_TEACHER') && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')) {
505
            return $this->json(['error' => 'You are not allowed to delete this course'], 403);
506
        }
507
508
        try {
509
            $payload = json_decode($req->getContent() ?: "{}", true);
510
            $confirm = trim((string)($payload['confirm'] ?? ''));
511
512
            if ($confirm === '') {
513
                return $this->json(['error' => 'Missing confirmation value'], 400);
514
            }
515
516
            // Current course
517
            $courseInfo = api_get_course_info();
518
            if (empty($courseInfo)) {
519
                return $this->json(['error' => 'Unable to resolve current course'], 400);
520
            }
521
522
            $officialCode = (string)($courseInfo['official_code'] ?? '');
523
            $runtimeCode  = (string)api_get_course_id();                 // often equals official code
524
            $sysCode      = (string)($courseInfo['sysCode'] ?? '');       // used by legacy delete
525
526
            if ($sysCode === '') {
527
                return $this->json(['error' => 'Invalid course system code'], 400);
528
            }
529
530
            // Accept either official_code or api_get_course_id() as confirmation
531
            $matches = \hash_equals($officialCode, $confirm) || \hash_equals($runtimeCode, $confirm);
532
            if (!$matches) {
533
                return $this->json(['error' => 'Course code confirmation mismatch'], 400);
534
            }
535
536
            // Legacy delete (removes course data + unregisters members in this course)
537
            // Throws on failure or returns void
538
            \CourseManager::delete_course($sysCode);
539
540
            // Best-effort cleanup of legacy course session flags
541
            try {
542
                $ses = $req->getSession();
543
                $ses?->remove('_cid');
544
                $ses?->remove('_real_cid');
545
            } catch (\Throwable) {
546
                // swallow — not critical
547
            }
548
549
            // Decide where to send the user afterwards
550
            // You can use '/index.php' or a landing page
551
            $redirectUrl = '/index.php';
552
553
            return $this->json([
554
                'ok'          => true,
555
                'message'     => 'Course deleted successfully',
556
                'redirectUrl' => $redirectUrl,
557
            ]);
558
        } catch (\Throwable $e) {
559
            return $this->json([
560
                'error'   => 'Failed to delete course: '.$e->getMessage(),
561
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
562
            ], 500);
563
        }
564
    }
565
566
    #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])]
567
    public function moodleExportOptions(int $node, Request $req): JsonResponse
568
    {
569
        $this->setDebugFromRequest($req);
570
571
        // Defaults for the UI
572
        $payload = [
573
            'versions' => [
574
                ['value' => '3', 'label' => 'Moodle 3.x'],
575
                ['value' => '4', 'label' => 'Moodle 4.x'],
576
            ],
577
            'defaults' => [
578
                'moodleVersion' => '4',
579
                'scope' => 'full', // 'full' | 'selected'
580
            ],
581
            // Optional friendly note until real export is implemented
582
            'message' => 'Moodle export endpoints are under construction.',
583
        ];
584
585
        return $this->json($payload);
586
    }
587
588
    #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])]
589
    public function moodleExportResources(int $node, Request $req): JsonResponse
590
    {
591
        $this->setDebugFromRequest($req);
592
593
        // Build legacy Course from CURRENT course (same approach as recycle)
594
        $cb = new CourseBuilder();
595
        $cb->set_tools_to_build([
596
            'documents','forums','tool_intro','links','quizzes','quiz_questions','assets','surveys',
597
            'survey_questions','announcements','events','course_descriptions','glossary','wiki',
598
            'thematic','attendance','works','gradebook','learnpath_category','learnpaths',
599
        ]);
600
        $course = $cb->build(0, api_get_course_id());
601
602
        $tree = $this->buildResourceTreeForVue($course);
603
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
604
605
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
606
    }
607
608
    #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])]
609
    public function moodleExportExecute(int $node, Request $req): JsonResponse
610
    {
611
        $this->setDebugFromRequest($req);
612
613
        // Read payload (basic validation)
614
        $p = json_decode($req->getContent() ?: "{}", true);
615
        $moodleVersion = (string)($p['moodleVersion'] ?? '4');     // '3' | '4'
616
        $scope         = (string)($p['scope'] ?? 'full');          // 'full' | 'selected'
617
        $adminId       = trim((string)($p['adminId'] ?? ''));
618
        $adminLogin    = trim((string)($p['adminLogin'] ?? ''));
619
        $adminEmail    = trim((string)($p['adminEmail'] ?? ''));
620
        $resources     = (array)($p['resources'] ?? []);
621
622
        if ($adminId === '' || $adminLogin === '' || $adminEmail === '') {
623
            return $this->json(['error' => 'Missing required fields (adminId, adminLogin, adminEmail)'], 400);
624
        }
625
        if ($scope === 'selected' && empty($resources)) {
626
            return $this->json(['error' => 'No resources selected'], 400);
627
        }
628
        if (!in_array($moodleVersion, ['3','4'], true)) {
629
            return $this->json(['error' => 'Unsupported Moodle version'], 400);
630
        }
631
632
        // Stub response while implementation is in progress
633
        // Use 202 so the frontend can show a notice without treating it as a failure.
634
        return new JsonResponse([
635
            'ok'      => false,
636
            'message' => 'Moodle export is under construction. No .mbz file was generated.',
637
            // you may also return a placeholder downloadUrl later
638
            // 'downloadUrl' => null,
639
        ], 202);
640
    }
641
642
    #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])]
643
    public function cc13ExportOptions(int $node, Request $req): JsonResponse
644
    {
645
        $this->setDebugFromRequest($req);
646
647
        return $this->json([
648
            'defaults' => [
649
                'scope' => 'full', // 'full' | 'selected'
650
            ],
651
            'message' => 'Common Cartridge 1.3 export is under construction. You can already pick items and submit.',
652
        ]);
653
    }
654
655
    #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])]
656
    public function cc13ExportResources(int $node, Request $req): JsonResponse
657
    {
658
        $this->setDebugFromRequest($req);
659
660
        $cb = new CourseBuilder();
661
        $cb->set_tools_to_build([
662
            'documents','forums','tool_intro','links','quizzes','quiz_questions','assets','surveys',
663
            'survey_questions','announcements','events','course_descriptions','glossary','wiki',
664
            'thematic','attendance','works','gradebook','learnpath_category','learnpaths',
665
        ]);
666
        $course = $cb->build(0, api_get_course_id());
667
668
        $tree = $this->buildResourceTreeForVue($course);
669
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
670
671
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
672
    }
673
674
    #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])]
675
    public function cc13ExportExecute(int $node, Request $req): JsonResponse
676
    {
677
        $this->setDebugFromRequest($req);
678
679
        $p        = json_decode($req->getContent() ?: "{}", true);
680
        $scope    = (string)($p['scope'] ?? 'full');   // 'full' | 'selected'
681
        $resources= (array)($p['resources'] ?? []);
682
683
        if (!in_array($scope, ['full', 'selected'], true)) {
684
            return $this->json(['error' => 'Unsupported scope'], 400);
685
        }
686
        if ($scope === 'selected' && empty($resources)) {
687
            return $this->json(['error' => 'No resources selected'], 400);
688
        }
689
690
        // TODO: Generate IMS CC 1.3 cartridge (.imscc or .zip)
691
        // For now, return an informative 202 “under construction”.
692
        return new JsonResponse([
693
            'ok'      => false,
694
            'message' => 'Common Cartridge 1.3 export is under construction. No file was generated.',
695
            // 'downloadUrl' => null, // set when implemented
696
        ], 202);
697
    }
698
699
    #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])]
700
    public function cc13Import(int $node, Request $req): JsonResponse
701
    {
702
        $this->setDebugFromRequest($req);
703
704
        $file = $req->files->get('file');
705
        if (!$file) {
706
            return $this->json(['error' => 'Missing file'], 400);
707
        }
708
        $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION));
709
        if (!in_array($ext, ['imscc','zip'], true)) {
710
            return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 400);
711
        }
712
713
        // TODO: Parse/restore CC 1.3. For now, just acknowledge.
714
        // You can temporarily move the uploaded file into a working dir if useful.
715
        return $this->json([
716
            'ok'      => true,
717
            'message' => 'CC 1.3 import endpoint is under construction. File received successfully.',
718
        ]);
719
    }
720
721
    // --------------------------------------------------------------------------------
722
    // Helpers to build the Vue-ready resource tree
723
    // --------------------------------------------------------------------------------
724
725
    /**
726
     * Build a Vue-friendly tree from legacy Course.
727
     *
728
     * @param object $course
729
     * @return array
730
     */
731
    private function buildResourceTreeForVue(object $course): array
732
    {
733
        if ($this->debug) {
734
            $this->logDebug('[buildResourceTreeForVue] start');
735
        }
736
737
        $resources = is_object($course) && isset($course->resources) && is_array($course->resources)
738
            ? $course->resources
739
            : [];
740
741
        $legacyTitles = [];
742
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
743
            /** @var array<string,string> $legacyTitles */
744
            $legacyTitles = CourseSelectForm::getResourceTitleList();
745
        }
746
        $fallbackTitles = $this->getDefaultTypeTitles();
747
        $skipTypes = $this->getSkipTypeKeys();
748
749
        $tree = [];
750
751
        // Forums block
752
        $hasForumData =
753
            (!empty($resources['forum']) || !empty($resources['Forum'])) ||
754
            (!empty($resources['forum_category']) || !empty($resources['Forum_Category'])) ||
755
            (!empty($resources['forum_topic']) || !empty($resources['ForumTopic'])) ||
756
            (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
757
758
        if ($hasForumData) {
759
            $tree[] = $this->buildForumTreeForVue(
760
                $course,
761
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
762
            );
763
            $skipTypes['forum']          = true;
764
            $skipTypes['forum_category'] = true;
765
            $skipTypes['forum_topic']    = true;
766
            $skipTypes['forum_post']     = true;
767
            $skipTypes['thread']         = true;
768
            $skipTypes['post']           = true;
769
        }
770
771
        // Other tools
772
        foreach ($resources as $rawType => $items) {
773
            if (!is_array($items) || empty($items)) {
774
                continue;
775
            }
776
            $typeKey = $this->normalizeTypeKey($rawType);
777
            if (isset($skipTypes[$typeKey])) {
778
                continue;
779
            }
780
781
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
782
            $group = [
783
                'type'  => $typeKey,
784
                'title' => (string) $groupTitle,
785
                'items' => [],
786
            ];
787
788
            if ($typeKey === 'gradebook') {
789
                $group['items'][] = [
790
                    'id'         => 'all',
791
                    'label'      => 'Gradebook (all)',
792
                    'extra'      => new \stdClass(),
793
                    'selectable' => true,
794
                ];
795
                $tree[] = $group;
796
                continue;
797
            }
798
799
            foreach ($items as $id => $obj) {
800
                if (!is_object($obj)) {
801
                    continue;
802
                }
803
804
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
805
                if ((is_int($idKey) && $idKey <= 0) || (is_string($idKey) && $idKey === '')) {
806
                    continue;
807
                }
808
809
                if (!$this->isSelectableItem($typeKey, $obj)) {
810
                    continue;
811
                }
812
813
                $label = $this->resolveItemLabel($typeKey, $obj, is_int($idKey) ? $idKey : 0);
814
                if ($typeKey === 'tool_intro' && $label === '#0' && is_string($idKey)) {
815
                    $label = $idKey;
816
                }
817
818
                $extra = $this->buildExtra($typeKey, $obj);
819
820
                $group['items'][] = [
821
                    'id'         => $idKey,
822
                    'label'      => $label,
823
                    'extra'      => $extra ?: new \stdClass(),
824
                    'selectable' => true,
825
                ];
826
            }
827
828
            if (!empty($group['items'])) {
829
                usort(
830
                    $group['items'],
831
                    static fn($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
832
                );
833
                $tree[] = $group;
834
            }
835
        }
836
837
        // Preferred order
838
        $preferredOrder = [
839
            'announcement','document','course_description','learnpath','quiz','forum','glossary','link',
840
            'survey','thematic','work','attendance','wiki','calendar_event','tool_intro','gradebook',
841
        ];
842
        usort($tree, static function ($a, $b) use ($preferredOrder) {
843
            $ia = array_search($a['type'], $preferredOrder, true);
844
            $ib = array_search($b['type'], $preferredOrder, true);
845
            if ($ia !== false && $ib !== false) {
846
                return $ia <=> $ib;
847
            }
848
            if ($ia !== false) {
849
                return -1;
850
            }
851
            if ($ib !== false) {
852
                return 1;
853
            }
854
            return strcasecmp($a['title'], $b['title']);
855
        });
856
857
        if ($this->debug) {
858
            $this->logDebug(
859
                '[buildResourceTreeForVue] end groups',
860
                array_map(fn($g) => ['type' => $g['type'], 'items' => count($g['items'] ?? [])], $tree)
861
            );
862
        }
863
864
        return $tree;
865
    }
866
867
    /**
868
     * Build forum tree (Category → Forum → Topic).
869
     *
870
     * @param object $course
871
     * @param string $groupTitle
872
     * @return array
873
     */
874
    private function buildForumTreeForVue(object $course, string $groupTitle): array
875
    {
876
        $this->logDebug('[buildForumTreeForVue] start');
877
878
        $res = is_array($course->resources ?? null) ? $course->resources : [];
879
880
        $catRaw   = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
881
        $forumRaw = $res['forum']          ?? $res['Forum']          ?? [];
882
        $topicRaw = $res['forum_topic']    ?? $res['ForumTopic']     ?? ($res['thread'] ?? []);
883
        $postRaw  = $res['forum_post']     ?? $res['Forum_Post']     ?? ($res['post'] ?? []);
884
885
        $this->logDebug('[buildForumTreeForVue] raw counts', [
886
            'categories' => is_array($catRaw) ? count($catRaw) : 0,
887
            'forums'     => is_array($forumRaw) ? count($forumRaw) : 0,
888
            'topics'     => is_array($topicRaw) ? count($topicRaw) : 0,
889
            'posts'      => is_array($postRaw) ? count($postRaw) : 0,
890
        ]);
891
892
        $cats = [];
893
        $forums = [];
894
        $topics = [];
895
        $postCountByTopic = [];
896
897
        foreach ($catRaw as $id => $obj) {
898
            $id = (int) $id;
899
            if ($id <= 0 || !is_object($obj)) {
900
                continue;
901
            }
902
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
903
            $cats[$id] = [
904
                'id'         => $id,
905
                'type'       => 'forum_category',
906
                'label'      => $label,
907
                'selectable' => false,
908
                'children'   => [],
909
            ];
910
        }
911
912
        foreach ($forumRaw as $id => $obj) {
913
            $id = (int) $id;
914
            if ($id <= 0 || !is_object($obj)) {
915
                continue;
916
            }
917
            $forums[$id] = $this->objectEntity($obj);
918
        }
919
920
        foreach ($topicRaw as $id => $obj) {
921
            $id = (int) $id;
922
            if ($id <= 0 || !is_object($obj)) {
923
                continue;
924
            }
925
            $topics[$id] = $this->objectEntity($obj);
926
        }
927
928
        foreach ($postRaw as $id => $obj) {
929
            $id = (int) $id;
930
            if ($id <= 0 || !is_object($obj)) {
931
                continue;
932
            }
933
            $e   = $this->objectEntity($obj);
934
            $tid = (int) ($e->thread_id ?? 0);
935
            if ($tid > 0) {
936
                $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1;
937
            }
938
        }
939
940
        $uncatKey = -9999;
941
        if (!isset($cats[$uncatKey])) {
942
            $cats[$uncatKey] = [
943
                'id'         => $uncatKey,
944
                'type'       => 'forum_category',
945
                'label'      => 'Uncategorized',
946
                'selectable' => false,
947
                'children'   => [],
948
                '_virtual'   => true,
949
            ];
950
        }
951
952
        foreach ($forums as $fid => $f) {
953
            $catId = (int) ($f->forum_category ?? 0);
954
            if (!isset($cats[$catId])) {
955
                $catId = $uncatKey;
956
            }
957
958
            $forumNode = [
959
                'id'         => $fid,
960
                'type'       => 'forum',
961
                'label'      => $this->resolveItemLabel('forum', $f, $fid),
962
                'extra'      => $this->buildExtra('forum', $f) ?: new \stdClass(),
963
                'selectable' => true,
964
                'children'   => [],
965
            ];
966
967
            foreach ($topics as $tid => $t) {
968
                if ((int) ($t->forum_id ?? 0) !== $fid) {
969
                    continue;
970
                }
971
972
                $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
973
                $date   = (string) ($t->thread_date ?? '');
974
                $nPosts = (int) ($postCountByTopic[$tid] ?? 0);
975
976
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
977
                $meta = [];
978
                if ($author !== '') {
979
                    $meta[] = $author;
980
                }
981
                if ($date !== '') {
982
                    $meta[] = $date;
983
                }
984
                if ($meta) {
985
                    $topicLabel .= ' ('.implode(', ', $meta).')';
986
                }
987
                if ($nPosts > 0) {
988
                    $topicLabel .= ' — '.$nPosts.' post'.($nPosts === 1 ? '' : 's');
989
                }
990
991
                $forumNode['children'][] = [
992
                    'id'         => $tid,
993
                    'type'       => 'forum_topic',
994
                    'label'      => $topicLabel,
995
                    'extra'      => new \stdClass(),
996
                    'selectable' => true,
997
                ];
998
            }
999
1000
            if ($forumNode['children']) {
1001
                usort($forumNode['children'], static fn($a, $b) => strcasecmp($a['label'], $b['label']));
1002
            }
1003
1004
            $cats[$catId]['children'][] = $forumNode;
1005
        }
1006
1007
        $catNodes = array_values(array_filter($cats, static function ($c) {
1008
            if (!empty($c['_virtual']) && empty($c['children'])) {
1009
                return false;
1010
            }
1011
            return true;
1012
        }));
1013
1014
        foreach ($catNodes as &$c) {
1015
            if (!empty($c['children'])) {
1016
                usort($c['children'], static fn($a, $b) => strcasecmp($a['label'], $b['label']));
1017
            }
1018
        }
1019
        unset($c);
1020
        usort($catNodes, static fn($a, $b) => strcasecmp($a['label'], $b['label']));
1021
1022
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => count($catNodes)]);
1023
1024
        return [
1025
            'type'  => 'forum',
1026
            'title' => $groupTitle,
1027
            'items' => $catNodes,
1028
        ];
1029
    }
1030
1031
    /**
1032
     * Normalize a raw type to a lowercase key.
1033
     *
1034
     * @param int|string $raw
1035
     * @return string
1036
     */
1037
    private function normalizeTypeKey(int|string $raw): string
1038
    {
1039
        if (is_int($raw)) {
1040
            return (string) $raw;
1041
        }
1042
1043
        $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw));
1044
1045
        $map = [
1046
            'forum_category'          => 'forum_category',
1047
            'forumtopic'              => 'forum_topic',
1048
            'forum_topic'             => 'forum_topic',
1049
            'forum_post'              => 'forum_post',
1050
            'thread'                  => 'forum_topic',
1051
            'post'                    => 'forum_post',
1052
            'exercise_question'       => 'exercise_question',
1053
            'surveyquestion'          => 'survey_question',
1054
            'surveyinvitation'        => 'survey_invitation',
1055
            'SurveyQuestion'          => 'survey_question',
1056
            'SurveyInvitation'        => 'survey_invitation',
1057
            'Survey'                  => 'survey',
1058
            'link_category'           => 'link_category',
1059
            'coursecopylearnpath'     => 'learnpath',
1060
            'coursecopytestcategory'  => 'test_category',
1061
            'coursedescription'       => 'course_description',
1062
            'session_course'          => 'session_course',
1063
            'gradebookbackup'         => 'gradebook',
1064
            'scormdocument'           => 'scorm',
1065
            'tool/introduction'       => 'tool_intro',
1066
            'tool_introduction'       => 'tool_intro',
1067
        ];
1068
1069
        return $map[$s] ?? $s;
1070
    }
1071
1072
    /**
1073
     * Keys to skip as top-level groups in UI.
1074
     *
1075
     * @return array<string,bool>
1076
     */
1077
    private function getSkipTypeKeys(): array
1078
    {
1079
        return [
1080
            'forum_category'     => true,
1081
            'forum_topic'        => true,
1082
            'forum_post'         => true,
1083
            'thread'             => true,
1084
            'post'               => true,
1085
            'exercise_question'  => true,
1086
            'survey_question'    => true,
1087
            'survey_invitation'  => true,
1088
            'session_course'     => true,
1089
            'scorm'              => true,
1090
            'asset'              => true,
1091
        ];
1092
    }
1093
1094
    /**
1095
     * Default labels for groups.
1096
     *
1097
     * @return array<string,string>
1098
     */
1099
    private function getDefaultTypeTitles(): array
1100
    {
1101
        return [
1102
            'announcement'        => 'Announcements',
1103
            'document'            => 'Documents',
1104
            'glossary'            => 'Glossaries',
1105
            'calendar_event'      => 'Calendar events',
1106
            'event'               => 'Calendar events',
1107
            'link'                => 'Links',
1108
            'course_description'  => 'Course descriptions',
1109
            'learnpath'           => 'Parcours',
1110
            'learnpath_category'  => 'Learning path categories',
1111
            'forum'               => 'Forums',
1112
            'forum_category'      => 'Forum categories',
1113
            'quiz'                => 'Exercices',
1114
            'test_category'       => 'Test categories',
1115
            'wiki'                => 'Wikis',
1116
            'thematic'            => 'Thematics',
1117
            'attendance'          => 'Attendances',
1118
            'work'                => 'Works',
1119
            'session_course'      => 'Session courses',
1120
            'gradebook'           => 'Gradebook',
1121
            'scorm'               => 'SCORM packages',
1122
            'survey'              => 'Surveys',
1123
            'survey_question'     => 'Survey questions',
1124
            'survey_invitation'   => 'Survey invitations',
1125
            'asset'               => 'Assets',
1126
            'tool_intro'          => 'Tool introductions',
1127
        ];
1128
    }
1129
1130
    /**
1131
     * Decide if an item is selectable (UI).
1132
     *
1133
     * @param string $type
1134
     * @param object $obj
1135
     * @return bool
1136
     */
1137
    private function isSelectableItem(string $type, object $obj): bool
1138
    {
1139
        if ($type === 'document') {
1140
            return true;
1141
        }
1142
        return true;
1143
    }
1144
1145
    /**
1146
     * Resolve label for an item with fallbacks.
1147
     *
1148
     * @param string $type
1149
     * @param object $obj
1150
     * @param int    $fallbackId
1151
     * @return string
1152
     */
1153
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
1154
    {
1155
        $entity = $this->objectEntity($obj);
1156
1157
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
1158
            if (isset($entity->$k) && is_string($entity->$k) && trim($entity->$k) !== '') {
1159
                return trim((string) $entity->$k);
1160
            }
1161
        }
1162
1163
        if (isset($obj->params) && is_array($obj->params)) {
1164
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
1165
                if (!empty($obj->params[$k]) && is_string($obj->params[$k])) {
1166
                    return (string) $obj->params[$k];
1167
                }
1168
            }
1169
        }
1170
1171
        switch ($type) {
1172
            case 'document':
1173
                if (!empty($obj->title)) {
1174
                    return (string) $obj->title;
1175
                }
1176
                if (!empty($obj->path)) {
1177
                    $base = basename((string) $obj->path);
1178
                    return $base !== '' ? $base : (string) $obj->path;
1179
                }
1180
                break;
1181
1182
            case 'course_description':
1183
                if (!empty($obj->title)) {
1184
                    return (string) $obj->title;
1185
                }
1186
                $t = (int) ($obj->description_type ?? 0);
1187
                $names = [
1188
                    1 => 'Description',
1189
                    2 => 'Objectives',
1190
                    3 => 'Topics',
1191
                    4 => 'Methodology',
1192
                    5 => 'Course material',
1193
                    6 => 'Resources',
1194
                    7 => 'Assessment',
1195
                    8 => 'Custom',
1196
                ];
1197
                return $names[$t] ?? ('#'.$fallbackId);
1198
1199
            case 'announcement':
1200
                if (!empty($obj->title)) {
1201
                    return (string) $obj->title;
1202
                }
1203
                break;
1204
1205
            case 'forum':
1206
                if (!empty($entity->forum_title)) {
1207
                    return (string) $entity->forum_title;
1208
                }
1209
                break;
1210
1211
            case 'forum_category':
1212
                if (!empty($entity->cat_title)) {
1213
                    return (string) $entity->cat_title;
1214
                }
1215
                break;
1216
1217
            case 'link':
1218
                if (!empty($obj->title)) {
1219
                    return (string) $obj->title;
1220
                }
1221
                if (!empty($obj->url)) {
1222
                    return (string) $obj->url;
1223
                }
1224
                break;
1225
1226
            case 'survey':
1227
                if (!empty($obj->title)) {
1228
                    return trim((string) $obj->title);
1229
                }
1230
                break;
1231
1232
            case 'learnpath':
1233
                if (!empty($obj->name)) {
1234
                    return (string) $obj->name;
1235
                }
1236
                break;
1237
1238
            case 'thematic':
1239
                if (isset($obj->params['title']) && is_string($obj->params['title'])) {
1240
                    return (string) $obj->params['title'];
1241
                }
1242
                break;
1243
1244
            case 'quiz':
1245
                if (!empty($entity->title)) {
1246
                    return (string) $entity->title;
1247
                }
1248
                break;
1249
1250
            case 'forum_topic':
1251
                if (!empty($entity->thread_title)) {
1252
                    return (string) $entity->thread_title;
1253
                }
1254
                break;
1255
        }
1256
1257
        return '#'.$fallbackId;
1258
    }
1259
1260
    /**
1261
     * Extract wrapped entity (->obj) or the object itself.
1262
     *
1263
     * @param object $resource
1264
     * @return object
1265
     */
1266
    private function objectEntity(object $resource): object
1267
    {
1268
        if (isset($resource->obj) && is_object($resource->obj)) {
1269
            return $resource->obj;
1270
        }
1271
        return $resource;
1272
    }
1273
1274
    /**
1275
     * Extra payload per item for UI (optional).
1276
     *
1277
     * @param string $type
1278
     * @param object $obj
1279
     * @return array
1280
     */
1281
    private function buildExtra(string $type, object $obj): array
1282
    {
1283
        $extra = [];
1284
1285
        $get = static function (object $o, string $k, $default = null) {
1286
            return (isset($o->$k) && (is_string($o->$k) || is_numeric($o->$k))) ? $o->$k : $default;
1287
        };
1288
1289
        switch ($type) {
1290
            case 'document':
1291
                $extra['path']     = (string) ($get($obj, 'path', '') ?? '');
1292
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
1293
                $extra['size']     = (string) ($get($obj, 'size', '') ?? '');
1294
                break;
1295
1296
            case 'link':
1297
                $extra['url']    = (string) ($get($obj, 'url', '') ?? '');
1298
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
1299
                break;
1300
1301
            case 'forum':
1302
                $entity = $this->objectEntity($obj);
1303
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
1304
                $extra['default_view'] = (string) ($entity->default_view ?? '');
1305
                break;
1306
1307
            case 'learnpath':
1308
                $extra['name']  = (string) ($get($obj, 'name', '') ?? '');
1309
                $extra['items'] = isset($obj->items) && is_array($obj->items) ? array_map(static function ($i) {
1310
                    return [
1311
                        'id'    => (int) ($i['id'] ?? 0),
1312
                        'title' => (string) ($i['title'] ?? ''),
1313
                        'type'  => (string) ($i['item_type'] ?? ''),
1314
                        'path'  => (string) ($i['path'] ?? ''),
1315
                    ];
1316
                }, $obj->items) : [];
1317
                break;
1318
1319
            case 'thematic':
1320
                if (isset($obj->params) && is_array($obj->params)) {
1321
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
1322
                }
1323
                break;
1324
1325
            case 'quiz':
1326
                $entity = $this->objectEntity($obj);
1327
                $extra['question_ids'] = isset($entity->question_ids) && is_array($entity->question_ids)
1328
                    ? array_map('intval', $entity->question_ids)
1329
                    : [];
1330
                break;
1331
1332
            case 'survey':
1333
                $entity = $this->objectEntity($obj);
1334
                $extra['question_ids'] = isset($entity->question_ids) && is_array($entity->question_ids)
1335
                    ? array_map('intval', $entity->question_ids)
1336
                    : [];
1337
                break;
1338
        }
1339
1340
        return array_filter($extra, static fn($v) => !($v === '' || $v === null || $v === []));
1341
    }
1342
1343
    // --------------------------------------------------------------------------------
1344
    // Selection filtering (used by partial restore)
1345
    // --------------------------------------------------------------------------------
1346
1347
    /**
1348
     * Get first existing key from candidates.
1349
     *
1350
     * @param array $orig
1351
     * @param array $candidates
1352
     * @return string|null
1353
     */
1354
    private function firstExistingKey(array $orig, array $candidates): ?string
1355
    {
1356
        foreach ($candidates as $k) {
1357
            if (isset($orig[$k]) && is_array($orig[$k]) && !empty($orig[$k])) {
1358
                return $k;
1359
            }
1360
        }
1361
        return null;
1362
    }
1363
1364
    /**
1365
     * Filter legacy Course by UI selections (and pull dependencies).
1366
     *
1367
     * @param object $course
1368
     * @param array  $selected [type => [id => true]]
1369
     * @return object
1370
     */
1371
    private function filterLegacyCourseBySelection(object $course, array $selected): object
1372
    {
1373
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
1374
1375
        if (empty($course->resources) || !is_array($course->resources)) {
1376
            $this->logDebug('[filterSelection] course has no resources');
1377
            return $course;
1378
        }
1379
        $orig = $course->resources;
1380
1381
        // Forums flow
1382
        $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'] ?? [])), true);
1383
        if (!empty($selForums)) {
1384
            $forums = $orig['forum'] ?? [];
1385
            $catsToKeep = [];
1386
            foreach ($forums as $fid => $f) {
1387
                if (!isset($selForums[(string) $fid])) {
1388
                    continue;
1389
                }
1390
                $e = isset($f->obj) && is_object($f->obj) ? $f->obj : $f;
1391
                $cid = (int) ($e->forum_category ?? 0);
1392
                if ($cid > 0) {
1393
                    $catsToKeep[$cid] = true;
1394
                }
1395
            }
1396
1397
            $threads = $orig['thread'] ?? [];
1398
            $threadToKeep = [];
1399
            foreach ($threads as $tid => $t) {
1400
                $e = isset($t->obj) && is_object($t->obj) ? $t->obj : $t;
1401
                if (isset($selForums[(string) ($e->forum_id ?? '')])) {
1402
                    $threadToKeep[(int) $tid] = true;
1403
                }
1404
            }
1405
1406
            $posts = $orig['post'] ?? [];
1407
            $postToKeep = [];
1408
            foreach ($posts as $pid => $p) {
1409
                $e = isset($p->obj) && is_object($p->obj) ? $p->obj : $p;
1410
                if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
1411
                    $postToKeep[(int) $pid] = true;
1412
                }
1413
            }
1414
1415
            $out = [];
1416
            foreach ($selected as $type => $ids) {
1417
                if (!is_array($ids) || empty($ids)) {
1418
                    continue;
1419
                }
1420
                if (!empty($orig[$type])) {
1421
                    $out[$type] = array_intersect_key($orig[$type], $ids);
1422
                }
1423
            }
1424
1425
            if (!empty($orig['Forum_Category'])) {
1426
                $out['Forum_Category'] = array_intersect_key(
1427
                    $orig['Forum_Category'],
1428
                    array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
1429
                );
1430
            }
1431
            if (!empty($orig['forum'])) {
1432
                $out['forum'] = array_intersect_key($orig['forum'], $selForums);
1433
            }
1434
            if (!empty($orig['thread'])) {
1435
                $out['thread'] = array_intersect_key(
1436
                    $orig['thread'],
1437
                    array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
1438
                );
1439
            }
1440
            if (!empty($orig['post'])) {
1441
                $out['post'] = array_intersect_key(
1442
                    $orig['post'],
1443
                    array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
1444
                );
1445
            }
1446
1447
            if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($orig['Forum_Category'])) {
1448
                $out['Forum_Category'] = $orig['Forum_Category'];
1449
            }
1450
1451
            $course->resources = array_filter($out);
1452
1453
            $this->logDebug('[filterSelection] end', [
1454
                'kept_types'   => array_keys($course->resources),
1455
                'forum_counts' => [
1456
                    'Forum_Category' => is_array($course->resources['Forum_Category'] ?? null) ? count($course->resources['Forum_Category']) : 0,
1457
                    'forum'          => is_array($course->resources['forum'] ?? null) ? count($course->resources['forum']) : 0,
1458
                    'thread'         => is_array($course->resources['thread'] ?? null) ? count($course->resources['thread']) : 0,
1459
                    'post'           => is_array($course->resources['post'] ?? null) ? count($course->resources['post']) : 0,
1460
                ],
1461
            ]);
1462
1463
            return $course;
1464
        }
1465
1466
        // Generic + quiz/survey/gradebook flows
1467
        $alias = [
1468
            'tool_intro' => 'Tool introduction',
1469
        ];
1470
1471
        $keep = [];
1472
        foreach ($selected as $type => $ids) {
1473
            if (!is_array($ids) || empty($ids)) {
1474
                continue;
1475
            }
1476
1477
            $legacyKey = $type;
1478
            if (!isset($orig[$legacyKey]) && isset($alias[$type])) {
1479
                $legacyKey = $alias[$type];
1480
            }
1481
1482
            if (!empty($orig[$legacyKey])) {
1483
                $keep[$legacyKey] = array_intersect_key($orig[$legacyKey], $ids);
1484
            }
1485
        }
1486
1487
        // Gradebook bucket
1488
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
1489
        if ($gbKey && !empty($selected['gradebook'])) {
1490
            $selIds = array_keys(array_filter((array) $selected['gradebook']));
1491
            $firstItem = is_array($orig[$gbKey]) ? reset($orig[$gbKey]) : null;
1492
1493
            if (in_array('all', $selIds, true) || !is_object($firstItem)) {
1494
                $keep[$gbKey] = $orig[$gbKey];
1495
                $this->logDebug('[filterSelection] kept full gradebook bucket', ['key' => $gbKey, 'count' => is_array($orig[$gbKey]) ? count($orig[$gbKey]) : 0]);
1496
            } else {
1497
                $keep[$gbKey] = array_intersect_key($orig[$gbKey], array_fill_keys(array_map('strval', $selIds), true));
1498
                $this->logDebug('[filterSelection] kept partial gradebook bucket', ['key' => $gbKey, 'count' => is_array($keep[$gbKey]) ? count($keep[$gbKey]) : 0]);
1499
            }
1500
        }
1501
1502
        // Quizzes → questions (+ images)
1503
        $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']);
1504
        if ($quizKey && !empty($keep[$quizKey])) {
1505
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', (defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '')]);
1506
            if ($questionKey) {
1507
                $qids = [];
1508
                foreach ($keep[$quizKey] as $qid => $qwrap) {
1509
                    $q = (isset($qwrap->obj) && is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
1510
                    if (!empty($q->question_ids) && is_array($q->question_ids)) {
1511
                        foreach ($q->question_ids as $sid) {
1512
                            $qids[(string) $sid] = true;
1513
                        }
1514
                    }
1515
                }
1516
1517
                if (!empty($qids)) {
1518
                    $selQ = array_intersect_key($orig[$questionKey], $qids);
1519
                    if (!empty($selQ)) {
1520
                        $keep[$questionKey] = $selQ;
1521
                        $this->logDebug('[filterSelection] pulled question bucket for quizzes', [
1522
                            'quiz_count'     => count($keep[$quizKey]),
1523
                            'question_key'   => $questionKey,
1524
                            'questions_kept' => count($keep[$questionKey]),
1525
                        ]);
1526
1527
                        $docKey = $this->firstExistingKey($orig, ['document', 'Document', (defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '')]);
1528
                        if ($docKey) {
1529
                            $imageQuizBucket = $orig[$docKey]['image_quiz'] ?? null;
1530
                            if (is_array($imageQuizBucket) && !empty($imageQuizBucket)) {
1531
                                $needed = [];
1532
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
1533
                                    $q = (isset($qwrap->obj) && is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
1534
                                    $pic = (string) ($q->picture ?? '');
1535
                                    if ($pic !== '' && isset($imageQuizBucket[$pic])) {
1536
                                        $needed[$pic] = true;
1537
                                    }
1538
                                }
1539
                                if (!empty($needed)) {
1540
                                    if (!isset($keep[$docKey]) || !is_array($keep[$docKey])) {
1541
                                        $keep[$docKey] = [];
1542
                                    }
1543
                                    if (!isset($keep[$docKey]['image_quiz']) || !is_array($keep[$docKey]['image_quiz'])) {
1544
                                        $keep[$docKey]['image_quiz'] = [];
1545
                                    }
1546
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
1547
                                    $this->logDebug('[filterSelection] included image_quiz docs for questions', [
1548
                                        'count' => count($keep[$docKey]['image_quiz']),
1549
                                    ]);
1550
                                }
1551
                            }
1552
                        }
1553
                    }
1554
                }
1555
            } else {
1556
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found in backup');
1557
            }
1558
        }
1559
1560
        // Surveys → questions (+ invitations)
1561
        $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']);
1562
        if ($surveyKey && !empty($keep[$surveyKey])) {
1563
            $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', (defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '')]);
1564
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', (defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '')]);
1565
1566
            if ($surveyQuestionKey) {
1567
                $neededQids = [];
1568
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
1569
1570
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
1571
                    $s = (isset($sWrap->obj) && is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
1572
                    if (!empty($s->question_ids) && is_array($s->question_ids)) {
1573
                        foreach ($s->question_ids as $qid) {
1574
                            $neededQids[(string) $qid] = true;
1575
                        }
1576
                    }
1577
                }
1578
1579
                if (empty($neededQids) && is_array($orig[$surveyQuestionKey])) {
1580
                    foreach ($orig[$surveyQuestionKey] as $qid => $qWrap) {
1581
                        $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
1582
                        $qSurveyId = (string) ($q->survey_id ?? '');
1583
                        if ($qSurveyId !== '' && in_array($qSurveyId, $selSurveyIds, true)) {
1584
                            $neededQids[(string) $qid] = true;
1585
                        }
1586
                    }
1587
                }
1588
1589
                if (!empty($neededQids)) {
1590
                    $keep[$surveyQuestionKey] = array_intersect_key($orig[$surveyQuestionKey], $neededQids);
1591
                    $this->logDebug('[filterSelection] pulled question bucket for surveys', [
1592
                        'survey_count'    => count($keep[$surveyKey]),
1593
                        'question_key'    => $surveyQuestionKey,
1594
                        'questions_kept'  => count($keep[$surveyQuestionKey]),
1595
                    ]);
1596
                } else {
1597
                    $this->logDebug('[filterSelection] surveys selected but no matching questions found');
1598
                }
1599
            } else {
1600
                $this->logDebug('[filterSelection] surveys selected but no question bucket found in backup');
1601
            }
1602
1603
            if ($surveyInvitationKey && !empty($orig[$surveyInvitationKey])) {
1604
                $neededInv = [];
1605
                foreach ($orig[$surveyInvitationKey] as $iid => $invWrap) {
1606
                    $inv = (isset($invWrap->obj) && is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
1607
                    $sid = (string) ($inv->survey_id ?? '');
1608
                    if ($sid !== '' && isset($keep[$surveyKey][$sid])) {
1609
                        $neededInv[(string) $iid] = true;
1610
                    }
1611
                }
1612
                if (!empty($neededInv)) {
1613
                    $keep[$surveyInvitationKey] = array_intersect_key($orig[$surveyInvitationKey], $neededInv);
1614
                    $this->logDebug('[filterSelection] included survey invitations', [
1615
                        'invitations_kept' => count($keep[$surveyInvitationKey]),
1616
                    ]);
1617
                }
1618
            }
1619
        }
1620
1621
        $course->resources = array_filter($keep);
1622
        $this->logDebug('[filterSelection] non-forum flow end', [
1623
            'kept_types' => array_keys($course->resources),
1624
        ]);
1625
1626
        return $course;
1627
    }
1628
1629
    /**
1630
     * Map UI options (1/2/3) to legacy file policy.
1631
     *
1632
     * @param int $opt
1633
     * @return int
1634
     */
1635
    private function mapSameNameOption(int $opt): int
1636
    {
1637
        $opt = in_array($opt, [1, 2, 3], true) ? $opt : 2;
1638
1639
        if (!defined('FILE_SKIP')) {
1640
            define('FILE_SKIP', 1);
1641
        }
1642
        if (!defined('FILE_RENAME')) {
1643
            define('FILE_RENAME', 2);
1644
        }
1645
        if (!defined('FILE_OVERWRITE')) {
1646
            define('FILE_OVERWRITE', 3);
1647
        }
1648
1649
        return match ($opt) {
1650
            1 => FILE_SKIP,
1651
            3 => FILE_OVERWRITE,
1652
            default => FILE_RENAME,
1653
        };
1654
    }
1655
1656
    /**
1657
     * Set debug mode from Request (query/header).
1658
     *
1659
     * @param Request|null $req
1660
     * @return void
1661
     */
1662
    private function setDebugFromRequest(?Request $req): void
1663
    {
1664
        if (!$req) {
1665
            return;
1666
        }
1667
        // Query param wins
1668
        if ($req->query->has('debug')) {
1669
            $this->debug = $req->query->getBoolean('debug');
1670
            return;
1671
        }
1672
        // Fallback to header
1673
        $hdr = $req->headers->get('X-Debug');
1674
        if ($hdr !== null) {
1675
            $val = trim((string) $hdr);
1676
            $this->debug = ($val !== '' && $val !== '0' && strcasecmp($val, 'false') !== 0);
1677
        }
1678
    }
1679
1680
    /**
1681
     * Debug logger with stage + compact JSON payload.
1682
     *
1683
     * @param string $stage
1684
     * @param mixed  $payload
1685
     * @return void
1686
     */
1687
    private function logDebug(string $stage, mixed $payload = null): void
1688
    {
1689
        if (!$this->debug) {
1690
            return;
1691
        }
1692
        $prefix = 'COURSE_DEBUG';
1693
        if ($payload === null) {
1694
            error_log("$prefix: $stage");
1695
            return;
1696
        }
1697
        // Safe/short json
1698
        $json = null;
1699
        try {
1700
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1701
            if ($json !== null && strlen($json) > 8000) {
1702
                $json = substr($json, 0, 8000).'…(truncated)';
1703
            }
1704
        } catch (\Throwable $e) {
1705
            $json = '[payload_json_error: '.$e->getMessage().']';
1706
        }
1707
        error_log("$prefix: $stage -> $json");
1708
    }
1709
1710
    /**
1711
     * Snapshot of resources bag for quick inspection.
1712
     *
1713
     * @param object $course
1714
     * @param int    $maxTypes
1715
     * @param int    $maxItemsPerType
1716
     * @return array
1717
     */
1718
    private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array
1719
    {
1720
        $out = [];
1721
        $res = is_array($course->resources ?? null) ? $course->resources : [];
1722
        $i = 0;
1723
        foreach ($res as $type => $bag) {
1724
            if ($i++ >= $maxTypes) {
1725
                $out['__notice'] = 'types truncated';
1726
                break;
1727
            }
1728
            $snap = ['count' => is_array($bag) ? count($bag) : 0, 'sample' => []];
1729
            if (is_array($bag)) {
1730
                $j = 0;
1731
                foreach ($bag as $id => $obj) {
1732
                    if ($j++ >= $maxItemsPerType) {
1733
                        $snap['sample'][] = ['__notice' => 'truncated'];
1734
                        break;
1735
                    }
1736
                    $entity = (is_object($obj) && isset($obj->obj) && is_object($obj->obj)) ? $obj->obj : $obj;
1737
                    $snap['sample'][] = [
1738
                        'id'          => (string) $id,
1739
                        'cls'         => is_object($obj) ? get_class($obj) : gettype($obj),
1740
                        'entity_keys' => is_object($entity) ? array_slice(array_keys((array) $entity), 0, 12) : [],
1741
                    ];
1742
                }
1743
            }
1744
            $out[(string) $type] = $snap;
1745
        }
1746
        return $out;
1747
    }
1748
1749
    /**
1750
     * Snapshot of forum-family counters.
1751
     *
1752
     * @param object $course
1753
     * @return array
1754
     */
1755
    private function snapshotForumCounts(object $course): array
1756
    {
1757
        $r = is_array($course->resources ?? null) ? $course->resources : [];
1758
        $get = fn($a, $b) => is_array(($r[$a] ?? $r[$b] ?? null)) ? count($r[$a] ?? $r[$b]) : 0;
1759
        return [
1760
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
1761
            'forum'          => $get('forum', 'Forum'),
1762
            'thread'         => $get('thread', 'forum_topic'),
1763
            'post'           => $get('post', 'forum_post'),
1764
        ];
1765
    }
1766
}
1767