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

CourseBuilder::addDocumentList()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 3
nc 3
nop 1
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
/* For licensing terms, see /license.txt */
3
4
declare(strict_types=1);
5
6
namespace Chamilo\CourseBundle\Component\CourseCopy;
7
8
use Chamilo\CoreBundle\Entity\Course as CourseEntity;
9
use Chamilo\CoreBundle\Entity\GradebookCategory;
10
use Chamilo\CoreBundle\Entity\GradebookEvaluation;
11
use Chamilo\CoreBundle\Entity\GradebookLink;
12
use Chamilo\CoreBundle\Entity\ResourceFile;
13
use Chamilo\CoreBundle\Entity\ResourceNode;
14
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
15
use Chamilo\CoreBundle\Framework\Container;
16
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
17
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Attendance;
18
use Chamilo\CourseBundle\Component\CourseCopy\Resources\CalendarEvent;
19
use Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseDescription;
20
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Document;
21
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Glossary;
22
use Chamilo\CourseBundle\Component\CourseCopy\Resources\GradeBookBackup;
23
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Thematic;
24
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Wiki;
25
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Work;
26
use Chamilo\CourseBundle\Entity\CAnnouncement;
27
use Chamilo\CourseBundle\Entity\CAnnouncementAttachment;
28
use Chamilo\CourseBundle\Entity\CAttendance;
29
use Chamilo\CourseBundle\Entity\CAttendanceCalendar;
30
use Chamilo\CourseBundle\Entity\CCalendarEvent;
31
use Chamilo\CourseBundle\Entity\CCalendarEventAttachment;
32
use Chamilo\CourseBundle\Entity\CCourseDescription;
33
use Chamilo\CourseBundle\Entity\CDocument;
34
use Chamilo\CourseBundle\Entity\CForum;
35
use Chamilo\CourseBundle\Entity\CForumCategory;
36
use Chamilo\CourseBundle\Entity\CForumPost;
37
use Chamilo\CourseBundle\Entity\CForumThread;
38
use Chamilo\CourseBundle\Entity\CGlossary;
39
use Chamilo\CourseBundle\Entity\CLink;
40
use Chamilo\CourseBundle\Entity\CLinkCategory;
41
use Chamilo\CourseBundle\Entity\CLp;
42
use Chamilo\CourseBundle\Entity\CLpCategory;
43
use Chamilo\CourseBundle\Entity\CLpItem;
44
use Chamilo\CourseBundle\Entity\CQuiz;
45
use Chamilo\CourseBundle\Entity\CQuizAnswer;
46
use Chamilo\CourseBundle\Entity\CQuizQuestion;
47
use Chamilo\CourseBundle\Entity\CQuizQuestionOption;
48
use Chamilo\CourseBundle\Entity\CQuizRelQuestion;
49
use Chamilo\CourseBundle\Entity\CStudentPublication;
50
use Chamilo\CourseBundle\Entity\CSurvey;
51
use Chamilo\CourseBundle\Entity\CSurveyQuestion;
52
use Chamilo\CourseBundle\Entity\CSurveyQuestionOption;
53
use Chamilo\CourseBundle\Entity\CThematic;
54
use Chamilo\CourseBundle\Entity\CThematicAdvance;
55
use Chamilo\CourseBundle\Entity\CThematicPlan;
56
use Chamilo\CourseBundle\Entity\CTool;
57
use Chamilo\CourseBundle\Entity\CToolIntro;
58
use Chamilo\CourseBundle\Entity\CWiki;
59
use Chamilo\CourseBundle\Repository\CDocumentRepository;
60
use Database;
61
use Doctrine\Common\Collections\Collection;
62
use Doctrine\ORM\EntityManagerInterface;
63
use Symfony\Component\HttpKernel\KernelInterface;
64
65
/**
66
 * CourseBuilder focused on Doctrine/ResourceNode export (keeps legacy orchestration).
67
 */
68
class CourseBuilder
69
{
70
    /** @var Course Legacy course container used by the exporter */
71
    public $course;
72
73
    /** @var array<string> Only the tools to build (defaults kept) */
74
    public array $tools_to_build = [
75
        'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions',
76
        'assets', 'surveys', 'survey_questions', 'announcements', 'events',
77
        'course_descriptions', 'glossary', 'wiki', 'thematic', 'attendance', 'works',
78
        'gradebook', 'learnpath_category', 'learnpaths',
79
    ];
80
81
    /** @var array<string, int|string> Legacy constant map (extend as you add tools) */
82
    public array $toolToName = [
83
        'documents'          => RESOURCE_DOCUMENT,
84
        'forums'             => RESOURCE_FORUM,
85
        'tool_intro'         => RESOURCE_TOOL_INTRO,
86
        'links'              => RESOURCE_LINK,
87
        'quizzes'            => RESOURCE_QUIZ,
88
        'quiz_questions'     => RESOURCE_QUIZQUESTION,
89
        'assets'             => 'asset',
90
        'surveys'            => RESOURCE_SURVEY,
91
        'survey_questions'   => RESOURCE_SURVEYQUESTION,
92
        'announcements'      => RESOURCE_ANNOUNCEMENT,
93
        'events'             => RESOURCE_EVENT,
94
        'course_descriptions'=> RESOURCE_COURSEDESCRIPTION,
95
        'glossary'           => RESOURCE_GLOSSARY,
96
        'wiki'               => RESOURCE_WIKI,
97
        'thematic'           => RESOURCE_THEMATIC,
98
        'attendance'         => RESOURCE_ATTENDANCE,
99
        'works'              => RESOURCE_WORK,
100
        'gradebook'          => RESOURCE_GRADEBOOK,
101
        'learnpaths'         => RESOURCE_LEARNPATH,
102
        'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
103
    ];
104
105
    /** @var array<string, array<int>> Optional whitelist of IDs per tool */
106
    public array $specific_id_list = [];
107
108
    /** @var array<int, array{0:string,1:string,2:string}> Documents referenced inside HTML */
109
    public array $documentsAddedInText = [];
110
111
    /** Doctrine services */
112
    private $em = null;       // Doctrine EntityManager
113
    private $docRepo = null;  // CDocumentRepository
114
115
    /**
116
     * Constructor (keeps legacy init; wires Doctrine repositories).
117
     *
118
     * @param string     $type   'partial'|'complete'
119
     * @param array|null $course Optional course info array
120
     */
121
    public function __construct($type = '', $course = null)
122
    {
123
        // Legacy behavior preserved
124
        $_course = api_get_course_info();
125
        if (!empty($course['official_code'])) {
126
            $_course = $course;
127
        }
128
129
        $this->course           = new Course();
130
        $this->course->code     = $_course['code'];
131
        $this->course->type     = $type;
132
        $this->course->encoding = api_get_system_encoding();
133
        $this->course->info     = $_course;
0 ignored issues
show
Bug introduced by
The property info does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
134
135
        $this->em     = Database::getManager();
136
        $this->docRepo = Container::getDocumentRepository();
137
138
        // Use $this->em / $this->docRepo in build_documents() when needed.
139
    }
140
141
    /**
142
     * Merge a parsed list of document refs into memory.
143
     *
144
     * @param array<int, array{0:string,1:string,2:string}> $list
145
     */
146
    public function addDocumentList(array $list): void
147
    {
148
        foreach ($list as $item) {
149
            if (!in_array($item[0], $this->documentsAddedInText, true)) {
150
                $this->documentsAddedInText[$item[0]] = $item;
151
            }
152
        }
153
    }
154
155
    /**
156
     * Parse HTML and collect referenced course documents.
157
     *
158
     * @param string $html HTML content
159
     */
160
    public function findAndSetDocumentsInText(string $html = ''): void
161
    {
162
        if ($html === '') {
163
            return;
164
        }
165
        $documentList = \DocumentManager::get_resources_from_source_html($html);
166
        $this->addDocumentList($documentList);
167
    }
168
169
    /**
170
     * Resolve collected HTML links to CDocument iids via the ResourceNode tree and build them.
171
     *
172
     * @return void
173
     */
174
    public function restoreDocumentsFromList(): void
175
    {
176
        if (empty($this->documentsAddedInText)) {
177
            return;
178
        }
179
180
        $courseInfo = api_get_course_info();
181
        $courseCode = (string) ($courseInfo['code'] ?? '');
182
        if ($courseCode === '') {
183
            return;
184
        }
185
186
        /** @var CourseEntity|null $course */
187
        $course = $this->em->getRepository(CourseEntity::class)->findOneBy(['code' => $courseCode]);
188
        if (!$course instanceof CourseEntity) {
189
            return;
190
        }
191
192
        // Documents root under the course
193
        $root = $this->docRepo->getCourseDocumentsRootNode($course);
194
        if (!$root instanceof ResourceNode) {
195
            return;
196
        }
197
198
        $iids = [];
199
200
        foreach ($this->documentsAddedInText as $item) {
201
            [$url, $scope, $type] = $item; // url, scope(local/remote), type(rel/abs/url)
202
            if ($scope !== 'local' || !\in_array($type, ['rel', 'abs'], true)) {
203
                continue;
204
            }
205
206
            $segments = $this->extractDocumentSegmentsFromUrl((string) $url);
207
            if (!$segments) {
208
                continue;
209
            }
210
211
            // Walk the ResourceNode tree by matching child titles
212
            $node = $this->resolveNodeBySegments($root, $segments);
213
            if (!$node) {
214
                continue;
215
            }
216
217
            $resource = $this->docRepo->getResourceByResourceNode($node);
218
            if ($resource instanceof CDocument && is_int($resource->getIid())) {
219
                $iids[] = $resource->getIid();
220
            }
221
        }
222
223
        $iids = array_values(array_unique($iids));
224
        if ($iids) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $iids of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
225
            $this->build_documents(
226
                api_get_session_id(),
227
                (int) $course->getId(),
228
                true,
229
                $iids
230
            );
231
        }
232
    }
233
234
    /**
235
     * Extract path segments after "/document".
236
     *
237
     * @param  string        $url
238
     * @return array<string>
239
     */
240
    private function extractDocumentSegmentsFromUrl(string $url): array
241
    {
242
        $decoded = urldecode($url);
243
        if (!preg_match('#/document(/.*)$#', $decoded, $m)) {
244
            return [];
245
        }
246
        $tail = trim($m[1], '/'); // e.g. "Folder/Sub/file.pdf"
247
        if ($tail === '') {
248
            return [];
249
        }
250
251
        $parts = array_values(array_filter(explode('/', $tail), static fn($s) => $s !== ''));
252
        return array_map(static fn($s) => trim($s), $parts);
253
    }
254
255
    /**
256
     * Walk children by title from a given parent node.
257
     *
258
     * @param  ResourceNode       $parent
259
     * @param  array<int,string>  $segments
260
     * @return ResourceNode|null
261
     */
262
    private function resolveNodeBySegments(ResourceNode $parent, array $segments): ?ResourceNode
263
    {
264
        $node = $parent;
265
        foreach ($segments as $title) {
266
            $child = $this->docRepo->findChildNodeByTitle($node, $title);
267
            if (!$child instanceof ResourceNode) {
268
                return null;
269
            }
270
            $node = $child;
271
        }
272
        return $node;
273
    }
274
275
    /**
276
     * Set tools to build.
277
     *
278
     * @param array<string> $array
279
     */
280
    public function set_tools_to_build(array $array): void
281
    {
282
        $this->tools_to_build = $array;
283
    }
284
285
    /**
286
     * Set specific id list per tool.
287
     *
288
     * @param array<string, array<int>> $array
289
     */
290
    public function set_tools_specific_id_list(array $array): void
291
    {
292
        $this->specific_id_list = $array;
293
    }
294
295
    /**
296
     * Get legacy Course container.
297
     *
298
     * @return Course
299
     */
300
    public function get_course(): Course
301
    {
302
        return $this->course;
303
    }
304
305
    /**
306
     * Build the course (documents already repo-based; other tools preserved).
307
     *
308
     * @param  int                $session_id
309
     * @param  string             $courseCode
310
     * @param  bool               $withBaseContent
311
     * @param  array<int|string>  $parseOnlyToolList
312
     * @param  array<string,mixed> $toolsFromPost
313
     * @return Course
314
     */
315
    public function build(
316
        int $session_id = 0,
317
        string $courseCode = '',
318
        bool $withBaseContent = false,
319
        array $parseOnlyToolList = [],
320
        array $toolsFromPost = []
321
    ): Course {
322
        /** @var CourseEntity|null $courseEntity */
323
        $courseEntity = $courseCode !== ''
324
            ? $this->em->getRepository(CourseEntity::class)->findOneBy(['code' => $courseCode])
325
            : $this->em->getRepository(CourseEntity::class)->find(api_get_course_int_id());
326
327
        /** @var SessionEntity|null $sessionEntity */
328
        $sessionEntity = $session_id
329
            ? $this->em->getRepository(SessionEntity::class)->find($session_id)
330
            : null;
331
332
        // Legacy DTO where resources[...] are built
333
        $legacyCourse = $this->course;
334
335
        foreach ($this->tools_to_build as $toolKey) {
336
            if (!empty($parseOnlyToolList)) {
337
                $const = $this->toolToName[$toolKey] ?? null;
338
                if ($const !== null && !in_array($const, $parseOnlyToolList, true)) {
339
                    continue;
340
                }
341
            }
342
343
            if ($toolKey === 'documents') {
344
                $ids = $this->specific_id_list['documents'] ?? [];
345
                $this->build_documents_with_repo($courseEntity, $sessionEntity, $withBaseContent, $ids);
346
            }
347
348
            if ($toolKey === 'forums' || $toolKey === 'forum') {
349
                $ids = $this->specific_id_list['forums'] ?? $this->specific_id_list['forum'] ?? [];
350
                $this->build_forum_category($legacyCourse, $courseEntity, $sessionEntity, $ids);
351
                $this->build_forums($legacyCourse, $courseEntity, $sessionEntity, $ids);
352
                $this->build_forum_topics($legacyCourse, $courseEntity, $sessionEntity, $ids);
353
                $this->build_forum_posts($legacyCourse, $courseEntity, $sessionEntity, $ids);
354
            }
355
356
            if ($toolKey === 'tool_intro') {
357
                $this->build_tool_intro($legacyCourse, $courseEntity, $sessionEntity);
358
            }
359
360
            if ($toolKey === 'links') {
361
                $ids = $this->specific_id_list['links'] ?? [];
362
                $this->build_links($legacyCourse, $courseEntity, $sessionEntity, $ids);
363
            }
364
365
            if ($toolKey === 'quizzes' || $toolKey === 'quiz') {
366
                $ids = $this->specific_id_list['quizzes'] ?? $this->specific_id_list['quiz'] ?? [];
367
                $neededQuestionIds = $this->build_quizzes($legacyCourse, $courseEntity, $sessionEntity, $ids);
368
                // Always export question bucket required by the quizzes
369
                $this->build_quiz_questions($legacyCourse, $courseEntity, $sessionEntity, $neededQuestionIds);
370
                error_log(
371
                    'COURSE_BUILD: quizzes='.count($legacyCourse->resources[RESOURCE_QUIZ] ?? []).
372
                    ' quiz_questions='.count($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? [])
373
                );
374
            }
375
376
            if ($toolKey === 'quiz_questions') {
377
                $ids = $this->specific_id_list['quiz_questions'] ?? [];
378
                $this->build_quiz_questions($legacyCourse, $courseEntity, $sessionEntity, $ids);
379
                error_log(
380
                    'COURSE_BUILD: explicit quiz_questions='.count($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? [])
381
                );
382
            }
383
384
            if ($toolKey === 'surveys' || $toolKey === 'survey') {
385
                $ids = $this->specific_id_list['surveys'] ?? $this->specific_id_list['survey'] ?? [];
386
                $neededQ = $this->build_surveys($this->course, $courseEntity, $sessionEntity, $ids);
387
                $this->build_survey_questions($this->course, $courseEntity, $sessionEntity, $neededQ);
388
            }
389
390
            if ($toolKey === 'survey_questions') {
391
                $this->build_survey_questions($this->course, $courseEntity, $sessionEntity, []);
392
            }
393
394
            if ($toolKey === 'announcements') {
395
                $ids = $this->specific_id_list['announcements'] ?? [];
396
                $this->build_announcements($this->course, $courseEntity, $sessionEntity, $ids);
397
            }
398
399
            if ($toolKey === 'events') {
400
                $ids = $this->specific_id_list['events'] ?? [];
401
                $this->build_events($this->course, $courseEntity, $sessionEntity, $ids);
402
            }
403
404
            if ($toolKey === 'course_descriptions') {
405
                $ids = $this->specific_id_list['course_descriptions'] ?? [];
406
                $this->build_course_descriptions($this->course, $courseEntity, $sessionEntity, $ids);
407
            }
408
409
            if ($toolKey === 'glossary') {
410
                $ids = $this->specific_id_list['glossary'] ?? [];
411
                $this->build_glossary($this->course, $courseEntity, $sessionEntity, $ids);
412
            }
413
414
            if ($toolKey === 'wiki') {
415
                $ids = $this->specific_id_list['wiki'] ?? [];
416
                $this->build_wiki($this->course, $courseEntity, $sessionEntity, $ids);
417
            }
418
419
            if ($toolKey === 'thematic') {
420
                $ids = $this->specific_id_list['thematic'] ?? [];
421
                $this->build_thematic($this->course, $courseEntity, $sessionEntity, $ids);
422
            }
423
424
            if ($toolKey === 'attendance') {
425
                $ids = $this->specific_id_list['attendance'] ?? [];
426
                $this->build_attendance($this->course, $courseEntity, $sessionEntity, $ids);
427
            }
428
429
            if ($toolKey === 'works') {
430
                $ids = $this->specific_id_list['works'] ?? [];
431
                $this->build_works($this->course, $courseEntity, $sessionEntity, $ids);
432
            }
433
434
            if ($toolKey === 'gradebook') {
435
                $this->build_gradebook($this->course, $courseEntity, $sessionEntity);
436
            }
437
438
            if ($toolKey === 'learnpath_category') {
439
                $ids = $this->specific_id_list['learnpath_category'] ?? [];
440
                $this->build_learnpath_category($this->course, $courseEntity, $sessionEntity, $ids);
441
            }
442
443
            if ($toolKey === 'learnpaths') {
444
                $ids = $this->specific_id_list['learnpaths'] ?? [];
445
                $this->build_learnpaths($this->course, $courseEntity, $sessionEntity, $ids, true);
446
            }
447
        }
448
449
        return $this->course;
450
    }
451
452
    /**
453
     * Export Learnpath categories (CLpCategory).
454
     *
455
     * @param  object            $legacyCourse
456
     * @param  CourseEntity|null $courseEntity
457
     * @param  SessionEntity|null $sessionEntity
458
     * @param  array<int>        $ids
459
     * @return void
460
     */
461
    private function build_learnpath_category(
462
        object $legacyCourse,
463
        ?CourseEntity $courseEntity,
464
        ?SessionEntity $sessionEntity,
465
        array $ids
466
    ): void {
467
        if (!$courseEntity instanceof CourseEntity) {
468
            return;
469
        }
470
471
        $repo = Container::getLpCategoryRepository();
472
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
473
474
        if (!empty($ids)) {
475
            $qb->andWhere('resource.iid IN (:ids)')
476
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
477
        }
478
479
        /** @var CLpCategory[] $rows */
480
        $rows = $qb->getQuery()->getResult();
481
482
        foreach ($rows as $cat) {
483
            $iid   = (int) $cat->getIid();
484
            $title = (string) $cat->getTitle();
485
486
            $payload = [
487
                'id'    => $iid,
488
                'title' => $title,
489
            ];
490
491
            $legacyCourse->resources[RESOURCE_LEARNPATH_CATEGORY][$iid] =
492
                $this->mkLegacyItem(RESOURCE_LEARNPATH_CATEGORY, $iid, $payload);
493
        }
494
    }
495
496
    /**
497
     * Export Learnpaths (CLp) + items, with optional SCORM folder packing.
498
     *
499
     * @param  object             $legacyCourse
500
     * @param  CourseEntity|null  $courseEntity
501
     * @param  SessionEntity|null $sessionEntity
502
     * @param  array<int>         $idList
503
     * @param  bool               $addScormFolder
504
     * @return void
505
     */
506
    private function build_learnpaths(
507
        object $legacyCourse,
508
        ?CourseEntity $courseEntity,
509
        ?SessionEntity $sessionEntity,
510
        array $idList = [],
511
        bool $addScormFolder = true
512
    ): void {
513
        if (!$courseEntity instanceof CourseEntity) {
514
            return;
515
        }
516
517
        $lpRepo = Container::getLpRepository();
518
        $qb     = $lpRepo->getResourcesByCourse($courseEntity, $sessionEntity);
519
520
        if (!empty($idList)) {
521
            $qb->andWhere('resource.iid IN (:ids)')
522
                ->setParameter('ids', array_values(array_unique(array_map('intval', $idList))));
523
        }
524
525
        /** @var CLp[] $lps */
526
        $lps = $qb->getQuery()->getResult();
527
528
        foreach ($lps as $lp) {
529
            $iid    = (int) $lp->getIid();
530
            $lpType = (int) $lp->getLpType(); // 1=LP, 2=SCORM, 3=AICC
531
532
            $items = [];
533
            /** @var CLpItem $it */
534
            foreach ($lp->getItems() as $it) {
535
                $items[] = [
536
                    'id'               => (int) $it->getIid(),
537
                    'item_type'        => (string) $it->getItemType(),
538
                    'ref'              => (string) $it->getRef(),
539
                    'title'            => (string) $it->getTitle(),
540
                    'name'             => (string) $lp->getTitle(),
541
                    'description'      => (string) ($it->getDescription() ?? ''),
542
                    'path'             => (string) $it->getPath(),
543
                    'min_score'        => (float) $it->getMinScore(),
544
                    'max_score'        => $it->getMaxScore() !== null ? (float) $it->getMaxScore() : null,
545
                    'mastery_score'    => $it->getMasteryScore() !== null ? (float) $it->getMasteryScore() : null,
546
                    'parent_item_id'   => (int) $it->getParentItemId(),
547
                    'previous_item_id' => $it->getPreviousItemId() !== null ? (int) $it->getPreviousItemId() : null,
548
                    'next_item_id'     => $it->getNextItemId() !== null ? (int) $it->getNextItemId() : null,
549
                    'display_order'    => (int) $it->getDisplayOrder(),
550
                    'prerequisite'     => (string) ($it->getPrerequisite() ?? ''),
551
                    'parameters'       => (string) ($it->getParameters() ?? ''),
552
                    'launch_data'      => (string) $it->getLaunchData(),
553
                    'audio'            => (string) ($it->getAudio() ?? ''),
554
                ];
555
            }
556
557
            $payload = [
558
                'id'               => $iid,
559
                'lp_type'          => $lpType,
560
                'title'            => (string) $lp->getTitle(),
561
                'path'             => (string) $lp->getPath(),
562
                'ref'              => (string) ($lp->getRef() ?? ''),
563
                'description'      => (string) ($lp->getDescription() ?? ''),
564
                'content_local'    => (string) $lp->getContentLocal(),
565
                'default_encoding' => (string) $lp->getDefaultEncoding(),
566
                'default_view_mod' => (string) $lp->getDefaultViewMod(),
567
                'prevent_reinit'   => (bool) $lp->getPreventReinit(),
568
                'force_commit'     => (bool) $lp->getForceCommit(),
569
                'content_maker'    => (string) $lp->getContentMaker(),
570
                'display_order'    => (int) $lp->getDisplayNotAllowedLp(),
571
                'js_lib'           => (string) $lp->getJsLib(),
572
                'content_license'  => (string) $lp->getContentLicense(),
573
                'debug'            => (bool) $lp->getDebug(),
574
                'visibility'       => '1',
575
                'author'           => (string) $lp->getAuthor(),
576
                'use_max_score'    => (int) $lp->getUseMaxScore(),
577
                'autolaunch'       => (int) $lp->getAutolaunch(),
578
                'created_on'       => $this->fmtDate($lp->getCreatedOn()),
579
                'modified_on'      => $this->fmtDate($lp->getModifiedOn()),
580
                'published_on'     => $this->fmtDate($lp->getPublishedOn()),
581
                'expired_on'       => $this->fmtDate($lp->getExpiredOn()),
582
                'session_id'       => (int) ($sessionEntity?->getId() ?? 0),
583
                'category_id'      => (int) ($lp->getCategory()?->getIid() ?? 0),
584
                'items'            => $items,
585
            ];
586
587
            $legacyCourse->resources[RESOURCE_LEARNPATH][$iid] =
588
                $this->mkLegacyItem(RESOURCE_LEARNPATH, $iid, $payload, ['items']);
589
        }
590
591
        // Optional: pack “scorm” folder (legacy parity)
592
        if ($addScormFolder && isset($this->course->backup_path)) {
593
            $scormDir = rtrim((string) $this->course->backup_path, '/') . '/scorm';
594
            if (is_dir($scormDir) && ($dh = @opendir($scormDir))) {
595
                $i = 1;
596
                while (false !== ($file = readdir($dh))) {
597
                    if ($file === '.' || $file === '..') {
598
                        continue;
599
                    }
600
                    if (is_dir($scormDir . '/' . $file)) {
601
                        $payload = ['path' => '/' . $file, 'name' => (string) $file];
602
                        $legacyCourse->resources['scorm'][$i] =
603
                            $this->mkLegacyItem('scorm', $i, $payload);
604
                        $i++;
605
                    }
606
                }
607
                closedir($dh);
608
            }
609
        }
610
    }
611
612
    /**
613
     * Export Gradebook (categories + evaluations + links).
614
     *
615
     * @param  object             $legacyCourse
616
     * @param  CourseEntity|null  $courseEntity
617
     * @param  SessionEntity|null $sessionEntity
618
     * @return void
619
     */
620
    private function build_gradebook(
621
        object $legacyCourse,
622
        ?CourseEntity $courseEntity,
623
        ?SessionEntity $sessionEntity
624
    ): void {
625
        if (!$courseEntity instanceof CourseEntity) {
626
            return;
627
        }
628
629
        /** @var EntityManagerInterface $em */
630
        $em = \Database::getManager();
631
        $catRepo = $em->getRepository(GradebookCategory::class);
632
633
        $criteria = ['course' => $courseEntity];
634
        if ($sessionEntity) {
635
            $criteria['session'] = $sessionEntity;
636
        }
637
638
        /** @var GradebookCategory[] $cats */
639
        $cats = $catRepo->findBy($criteria);
640
        if (!$cats) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cats of type Chamilo\CoreBundle\Entity\GradebookCategory[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
641
            return;
642
        }
643
644
        $payloadCategories = [];
645
        foreach ($cats as $cat) {
646
            $payloadCategories[] = $this->serializeGradebookCategory($cat);
647
        }
648
649
        $backup = new GradeBookBackup($payloadCategories);
650
        $legacyCourse->add_resource($backup);
651
    }
652
653
    /**
654
     * Serialize GradebookCategory (and nested parts) to array for restore.
655
     *
656
     * @param  GradebookCategory $c
657
     * @return array<string,mixed>
658
     */
659
    private function serializeGradebookCategory(GradebookCategory $c): array
660
    {
661
        $arr = [
662
            'id'                         => (int) $c->getId(),
663
            'title'                      => (string) $c->getTitle(),
664
            'description'                => (string) ($c->getDescription() ?? ''),
665
            'weight'                     => (float) $c->getWeight(),
666
            'visible'                    => (bool) $c->getVisible(),
667
            'locked'                     => (int) $c->getLocked(),
668
            'parent_id'                  => $c->getParent() ? (int) $c->getParent()->getId() : 0,
669
            'generate_certificates'      => (bool) $c->getGenerateCertificates(),
670
            'certificate_validity_period'=> $c->getCertificateValidityPeriod(),
671
            'is_requirement'             => (bool) $c->getIsRequirement(),
672
            'default_lowest_eval_exclude'=> (bool) $c->getDefaultLowestEvalExclude(),
673
            'minimum_to_validate'        => $c->getMinimumToValidate(),
674
            'gradebooks_to_validate_in_dependence' => $c->getGradeBooksToValidateInDependence(),
675
            'allow_skills_by_subcategory'=> $c->getAllowSkillsBySubcategory(),
676
            // camelCase duplicates (future-proof)
677
            'generateCertificates'       => (bool) $c->getGenerateCertificates(),
678
            'certificateValidityPeriod'  => $c->getCertificateValidityPeriod(),
679
            'isRequirement'              => (bool) $c->getIsRequirement(),
680
            'defaultLowestEvalExclude'   => (bool) $c->getDefaultLowestEvalExclude(),
681
            'minimumToValidate'          => $c->getMinimumToValidate(),
682
            'gradeBooksToValidateInDependence' => $c->getGradeBooksToValidateInDependence(),
683
            'allowSkillsBySubcategory'   => $c->getAllowSkillsBySubcategory(),
684
        ];
685
686
        if ($c->getGradeModel()) {
687
            $arr['grade_model_id'] = (int) $c->getGradeModel()->getId();
688
        }
689
690
        // Evaluations
691
        $arr['evaluations'] = [];
692
        foreach ($c->getEvaluations() as $e) {
693
            /** @var GradebookEvaluation $e */
694
            $arr['evaluations'][] = [
695
                'title'         => (string) $e->getTitle(),
696
                'description'   => (string) ($e->getDescription() ?? ''),
697
                'weight'        => (float)  $e->getWeight(),
698
                'max'           => (float)  $e->getMax(),
699
                'type'          => (string) $e->getType(),
700
                'visible'       => (int)    $e->getVisible(),
701
                'locked'        => (int)    $e->getLocked(),
702
                'best_score'    => $e->getBestScore(),
703
                'average_score' => $e->getAverageScore(),
704
                'score_weight'  => $e->getScoreWeight(),
705
                'min_score'     => $e->getMinScore(),
706
            ];
707
        }
708
709
        // Links
710
        $arr['links'] = [];
711
        foreach ($c->getLinks() as $l) {
712
            /** @var GradebookLink $l */
713
            $arr['links'][] = [
714
                'type'          => (int)   $l->getType(),
715
                'ref_id'        => (int)   $l->getRefId(),
716
                'weight'        => (float) $l->getWeight(),
717
                'visible'       => (int)   $l->getVisible(),
718
                'locked'        => (int)   $l->getLocked(),
719
                'best_score'    => $l->getBestScore(),
720
                'average_score' => $l->getAverageScore(),
721
                'score_weight'  => $l->getScoreWeight(),
722
                'min_score'     => $l->getMinScore(),
723
            ];
724
        }
725
726
        return $arr;
727
    }
728
729
    /**
730
     * Export Works (root folders only; include assignment params).
731
     *
732
     * @param  object             $legacyCourse
733
     * @param  CourseEntity|null  $courseEntity
734
     * @param  SessionEntity|null $sessionEntity
735
     * @param  array<int>         $ids
736
     * @return void
737
     */
738
    private function build_works(
739
        object $legacyCourse,
740
        ?CourseEntity $courseEntity,
741
        ?SessionEntity $sessionEntity,
742
        array $ids
743
    ): void {
744
        if (!$courseEntity instanceof CourseEntity) {
745
            return;
746
        }
747
748
        $repo = Container::getStudentPublicationRepository();
749
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
750
751
        $qb
752
            ->andWhere('resource.publicationParent IS NULL')
753
            ->andWhere('resource.filetype = :ft')->setParameter('ft', 'folder')
754
            ->andWhere('resource.active = 1');
755
756
        if (!empty($ids)) {
757
            $qb->andWhere('resource.iid IN (:ids)')
758
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
759
        }
760
761
        /** @var CStudentPublication[] $rows */
762
        $rows = $qb->getQuery()->getResult();
763
764
        foreach ($rows as $row) {
765
            $iid   = (int) $row->getIid();
766
            $title = (string) $row->getTitle();
767
            $desc  = (string) ($row->getDescription() ?? '');
768
769
            // Detect documents linked in description
770
            $this->findAndSetDocumentsInText($desc);
771
772
            $asgmt     = $row->getAssignment();
773
            $expiresOn = $asgmt?->getExpiresOn()?->format('Y-m-d H:i:s');
774
            $endsOn    = $asgmt?->getEndsOn()?->format('Y-m-d H:i:s');
775
            $addToCal  = $asgmt && $asgmt->getEventCalendarId() > 0 ? 1 : 0;
776
            $enableQ   = (bool) ($asgmt?->getEnableQualification() ?? false);
777
778
            $params = [
779
                'id'                             => $iid,
780
                'title'                          => $title,
781
                'description'                    => $desc,
782
                'weight'                         => (float) $row->getWeight(),
783
                'qualification'                  => (float) $row->getQualification(),
784
                'allow_text_assignment'          => (int)   $row->getAllowTextAssignment(),
785
                'default_visibility'             => (bool)  ($row->getDefaultVisibility() ?? false),
786
                'student_delete_own_publication' => (bool)  ($row->getStudentDeleteOwnPublication() ?? false),
787
                'extensions'                     => $row->getExtensions(),
788
                'group_category_work_id'         => (int)   $row->getGroupCategoryWorkId(),
789
                'post_group_id'                  => (int)   $row->getPostGroupId(),
790
                'enable_qualification'           => $enableQ,
791
                'add_to_calendar'                => $addToCal ? 1 : 0,
792
                'expires_on'                     => $expiresOn ?: null,
793
                'ends_on'                        => $endsOn   ?: null,
794
                'name'                           => $title,
795
                'url'                            => null,
796
            ];
797
798
            $legacy = new Work($params);
799
            $legacyCourse->add_resource($legacy);
800
        }
801
    }
802
803
    /**
804
     * Export Attendance + calendars.
805
     *
806
     * @param  object             $legacyCourse
807
     * @param  CourseEntity|null  $courseEntity
808
     * @param  SessionEntity|null $sessionEntity
809
     * @param  array<int>         $ids
810
     * @return void
811
     */
812
    private function build_attendance(
813
        object $legacyCourse,
814
        ?CourseEntity $courseEntity,
815
        ?SessionEntity $sessionEntity,
816
        array $ids
817
    ): void {
818
        if (!$courseEntity instanceof CourseEntity) {
819
            return;
820
        }
821
822
        $repo = Container::getAttendanceRepository();
823
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
824
825
        if (!empty($ids)) {
826
            $qb->andWhere('resource.iid IN (:ids)')
827
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
828
        }
829
830
        /** @var CAttendance[] $rows */
831
        $rows = $qb->getQuery()->getResult();
832
833
        foreach ($rows as $row) {
834
            $iid    = (int) $row->getIid();
835
            $title  = (string) $row->getTitle();
836
            $desc   = (string) ($row->getDescription() ?? '');
837
            $active = (int) $row->getActive();
838
839
            $this->findAndSetDocumentsInText($desc);
840
841
            $params = [
842
                'id'                       => $iid,
843
                'title'                    => $title,
844
                'description'              => $desc,
845
                'active'                   => $active,
846
                'attendance_qualify_title' => (string) ($row->getAttendanceQualifyTitle() ?? ''),
847
                'attendance_qualify_max'   => (int) $row->getAttendanceQualifyMax(),
848
                'attendance_weight'        => (float) $row->getAttendanceWeight(),
849
                'locked'                   => (int) $row->getLocked(),
850
                'name'                     => $title,
851
            ];
852
853
            $legacy = new Attendance($params);
854
855
            /** @var CAttendanceCalendar $cal */
856
            foreach ($row->getCalendars() as $cal) {
857
                $calArr = [
858
                    'id'              => (int) $cal->getIid(),
859
                    'attendance_id'   => $iid,
860
                    'date_time'       => $cal->getDateTime()?->format('Y-m-d H:i:s') ?? '',
861
                    'done_attendance' => (bool) $cal->getDoneAttendance(),
862
                    'blocked'         => (bool) $cal->getBlocked(),
863
                    'duration'        => $cal->getDuration() !== null ? (int) $cal->getDuration() : null,
864
                ];
865
                $legacy->add_attendance_calendar($calArr);
866
            }
867
868
            $legacyCourse->add_resource($legacy);
869
        }
870
    }
871
872
    /**
873
     * Export Thematic + advances + plans (and collect linked docs).
874
     *
875
     * @param  object             $legacyCourse
876
     * @param  CourseEntity|null  $courseEntity
877
     * @param  SessionEntity|null $sessionEntity
878
     * @param  array<int>         $ids
879
     * @return void
880
     */
881
    private function build_thematic(
882
        object $legacyCourse,
883
        ?CourseEntity $courseEntity,
884
        ?SessionEntity $sessionEntity,
885
        array $ids
886
    ): void {
887
        if (!$courseEntity instanceof CourseEntity) {
888
            return;
889
        }
890
891
        $repo = Container::getThematicRepository();
892
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
893
894
        if (!empty($ids)) {
895
            $qb->andWhere('resource.iid IN (:ids)')
896
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
897
        }
898
899
        /** @var CThematic[] $rows */
900
        $rows = $qb->getQuery()->getResult();
901
902
        foreach ($rows as $row) {
903
            $iid     = (int) $row->getIid();
904
            $title   = (string) $row->getTitle();
905
            $content = (string) ($row->getContent() ?? '');
906
            $active  = (bool) $row->getActive();
907
908
            $this->findAndSetDocumentsInText($content);
909
910
            $params = [
911
                'id'      => $iid,
912
                'title'   => $title,
913
                'content' => $content,
914
                'active'  => $active,
915
            ];
916
917
            $legacy = new Thematic($params);
918
919
            /** @var CThematicAdvance $adv */
920
            foreach ($row->getAdvances() as $adv) {
921
                $attendanceId = 0;
922
                try {
923
                    $refAtt = new \ReflectionProperty(CThematicAdvance::class, 'attendance');
924
                    if ($refAtt->isInitialized($adv)) {
925
                        $att = $adv->getAttendance();
926
                        if ($att) {
927
                            $attendanceId = (int) $att->getIid();
928
                        }
929
                    }
930
                } catch (\Throwable) {
931
                    // keep $attendanceId = 0
932
                }
933
934
                $advArr = [
935
                    'id'            => (int) $adv->getIid(),
936
                    'thematic_id'   => (int) $row->getIid(),
937
                    'content'       => (string) ($adv->getContent() ?? ''),
938
                    'start_date'    => $adv->getStartDate()?->format('Y-m-d H:i:s') ?? '',
939
                    'duration'      => (int) $adv->getDuration(),
940
                    'done_advance'  => (bool) $adv->getDoneAdvance(),
941
                    'attendance_id' => $attendanceId,
942
                    'room_id'       => (int) ($adv->getRoom()?->getId() ?? 0),
943
                ];
944
945
                $this->findAndSetDocumentsInText((string) $advArr['content']);
946
                $legacy->addThematicAdvance($advArr);
947
            }
948
949
            /** @var CThematicPlan $pl */
950
            foreach ($row->getPlans() as $pl) {
951
                $plArr = [
952
                    'id'               => (int) $pl->getIid(),
953
                    'thematic_id'      => $iid,
954
                    'title'            => (string) $pl->getTitle(),
955
                    'description'      => (string) ($pl->getDescription() ?? ''),
956
                    'description_type' => (int) $pl->getDescriptionType(),
957
                ];
958
                $this->findAndSetDocumentsInText((string) $plArr['description']);
959
                $legacy->addThematicPlan($plArr);
960
            }
961
962
            $legacyCourse->add_resource($legacy);
963
        }
964
    }
965
966
    /**
967
     * Export Wiki pages (content + metadata; collect docs in content).
968
     *
969
     * @param  object             $legacyCourse
970
     * @param  CourseEntity|null  $courseEntity
971
     * @param  SessionEntity|null $sessionEntity
972
     * @param  array<int>         $ids
973
     * @return void
974
     */
975
    private function build_wiki(
976
        object $legacyCourse,
977
        ?CourseEntity $courseEntity,
978
        ?SessionEntity $sessionEntity,
979
        array $ids
980
    ): void {
981
        if (!$courseEntity instanceof CourseEntity) {
982
            return;
983
        }
984
985
        $repo = Container::getWikiRepository();
986
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
987
988
        if (!empty($ids)) {
989
            $qb->andWhere('resource.iid IN (:ids)')
990
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
991
        }
992
993
        /** @var CWiki[] $pages */
994
        $pages = $qb->getQuery()->getResult();
995
996
        foreach ($pages as $page) {
997
            $iid      = (int) $page->getIid();
998
            $pageId   = (int) ($page->getPageId() ?? $iid);
999
            $reflink  = (string) $page->getReflink();
1000
            $title    = (string) $page->getTitle();
1001
            $content  = (string) $page->getContent();
1002
            $userId   = (int) $page->getUserId();
1003
            $groupId  = (int) ($page->getGroupId() ?? 0);
1004
            $progress = (string) ($page->getProgress() ?? '');
1005
            $version  = (int) ($page->getVersion() ?? 1);
1006
            $dtime    = $page->getDtime()?->format('Y-m-d H:i:s') ?? '';
1007
1008
            $this->findAndSetDocumentsInText($content);
1009
1010
            $legacy = new Wiki(
1011
                $iid,
1012
                $pageId,
1013
                $reflink,
1014
                $title,
1015
                $content,
1016
                $userId,
1017
                $groupId,
1018
                $dtime,
1019
                $progress,
1020
                $version
1021
            );
1022
1023
            $this->course->add_resource($legacy);
1024
        }
1025
    }
1026
1027
    /**
1028
     * Export Glossary terms (collect docs in descriptions).
1029
     *
1030
     * @param  object             $legacyCourse
1031
     * @param  CourseEntity|null  $courseEntity
1032
     * @param  SessionEntity|null $sessionEntity
1033
     * @param  array<int>         $ids
1034
     * @return void
1035
     */
1036
    private function build_glossary(
1037
        object $legacyCourse,
1038
        ?CourseEntity $courseEntity,
1039
        ?SessionEntity $sessionEntity,
1040
        array $ids
1041
    ): void {
1042
        if (!$courseEntity instanceof CourseEntity) {
1043
            return;
1044
        }
1045
1046
        $repo = Container::getGlossaryRepository();
1047
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
1048
1049
        if (!empty($ids)) {
1050
            $qb->andWhere('resource.iid IN (:ids)')
1051
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
1052
        }
1053
1054
        /** @var CGlossary[] $terms */
1055
        $terms = $qb->getQuery()->getResult();
1056
1057
        foreach ($terms as $term) {
1058
            $iid   = (int) $term->getIid();
1059
            $title = (string) $term->getTitle();
1060
            $desc  = (string) ($term->getDescription() ?? '');
1061
1062
            $this->findAndSetDocumentsInText($desc);
1063
1064
            $legacy = new Glossary(
1065
                $iid,
1066
                $title,
1067
                $desc,
1068
                0
1069
            );
1070
1071
            $this->course->add_resource($legacy);
1072
        }
1073
    }
1074
1075
    /**
1076
     * Export Course descriptions (collect docs in HTML).
1077
     *
1078
     * @param  object             $legacyCourse
1079
     * @param  CourseEntity|null  $courseEntity
1080
     * @param  SessionEntity|null $sessionEntity
1081
     * @param  array<int>         $ids
1082
     * @return void
1083
     */
1084
    private function build_course_descriptions(
1085
        object $legacyCourse,
1086
        ?CourseEntity $courseEntity,
1087
        ?SessionEntity $sessionEntity,
1088
        array $ids
1089
    ): void {
1090
        if (!$courseEntity instanceof CourseEntity) {
1091
            return;
1092
        }
1093
1094
        $repo = Container::getCourseDescriptionRepository();
1095
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
1096
1097
        if (!empty($ids)) {
1098
            $qb->andWhere('resource.iid IN (:ids)')
1099
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
1100
        }
1101
1102
        /** @var CCourseDescription[] $rows */
1103
        $rows = $qb->getQuery()->getResult();
1104
1105
        foreach ($rows as $row) {
1106
            $iid   = (int) $row->getIid();
1107
            $title = (string) ($row->getTitle() ?? '');
1108
            $html  = (string) ($row->getContent() ?? '');
1109
            $type  = (int) $row->getDescriptionType();
1110
1111
            $this->findAndSetDocumentsInText($html);
1112
1113
            $export = new CourseDescription(
1114
                $iid,
1115
                $title,
1116
                $html,
1117
                $type
1118
            );
1119
1120
            $this->course->add_resource($export);
1121
        }
1122
    }
1123
1124
    /**
1125
     * Export Calendar events (first attachment as legacy, all as assets).
1126
     *
1127
     * @param  object             $legacyCourse
1128
     * @param  CourseEntity|null  $courseEntity
1129
     * @param  SessionEntity|null $sessionEntity
1130
     * @param  array<int>         $ids
1131
     * @return void
1132
     */
1133
    private function build_events(
1134
        object $legacyCourse,
1135
        ?CourseEntity $courseEntity,
1136
        ?SessionEntity $sessionEntity,
1137
        array $ids
1138
    ): void {
1139
        if (!$courseEntity instanceof CourseEntity) {
1140
            return;
1141
        }
1142
1143
        $eventRepo = Container::getCalendarEventRepository();
1144
        $qb        = $eventRepo->getResourcesByCourse($courseEntity, $sessionEntity);
1145
1146
        if (!empty($ids)) {
1147
            $qb->andWhere('resource.iid IN (:ids)')
1148
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
1149
        }
1150
1151
        /** @var CCalendarEvent[] $events */
1152
        $events = $qb->getQuery()->getResult();
1153
1154
        /** @var KernelInterface $kernel */
1155
        $kernel      = Container::$container->get('kernel');
0 ignored issues
show
Bug introduced by
The method get() does not exist on null. ( Ignorable by Annotation )

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

1155
        /** @scrutinizer ignore-call */ 
1156
        $kernel      = Container::$container->get('kernel');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1156
        $projectDir  = rtrim($kernel->getProjectDir(), '/');
1157
        $resourceBase = $projectDir . '/var/upload/resource';
1158
1159
        /** @var ResourceNodeRepository $rnRepo */
1160
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
1161
1162
        foreach ($events as $ev) {
1163
            $iid       = (int) $ev->getIid();
1164
            $title     = (string) $ev->getTitle();
1165
            $content   = (string) ($ev->getContent() ?? '');
1166
            $startDate = $ev->getStartDate()?->format('Y-m-d H:i:s') ?? '';
1167
            $endDate   = $ev->getEndDate()?->format('Y-m-d H:i:s') ?? '';
1168
            $allDay    = (int) $ev->isAllDay();
1169
1170
            $firstPath = $firstName = $firstComment = '';
1171
            $firstSize = 0;
1172
1173
            /** @var CCalendarEventAttachment $att */
1174
            foreach ($ev->getAttachments() as $att) {
1175
                $node = $att->getResourceNode();
1176
                $abs  = null;
1177
                $size = 0;
1178
                $relForZip = null;
1179
1180
                if ($node) {
1181
                    $file = $node->getFirstResourceFile();
1182
                    if ($file) {
1183
                        $storedRel = (string) $rnRepo->getFilename($file);
1184
                        if ($storedRel !== '') {
1185
                            $candidate = $resourceBase . $storedRel;
1186
                            if (is_readable($candidate)) {
1187
                                $abs  = $candidate;
1188
                                $size = (int) $file->getSize();
1189
                                if ($size <= 0 && is_file($candidate)) {
1190
                                    $st   = @stat($candidate);
1191
                                    $size = $st ? (int) $st['size'] : 0;
1192
                                }
1193
                                $base = basename($storedRel) ?: (string) $att->getIid();
1194
                                $relForZip = 'upload/calendar/' . $base;
1195
                            }
1196
                        }
1197
                    }
1198
                }
1199
1200
                if ($abs && $relForZip) {
1201
                    $this->tryAddAsset($relForZip, $abs, $size);
1202
                } else {
1203
                    error_log('COURSE_BUILD: event attachment file not found (event_iid='
1204
                        . $iid . '; att_iid=' . (int) $att->getIid() . ')');
1205
                }
1206
1207
                if ($firstName === '' && $relForZip) {
1208
                    $firstPath    = substr($relForZip, strlen('upload/calendar/'));
1209
                    $firstName    = (string) $att->getFilename();
1210
                    $firstComment = (string) ($att->getComment() ?? '');
1211
                    $firstSize    = (int) $size;
1212
                }
1213
            }
1214
1215
            $export = new CalendarEvent(
1216
                $iid,
1217
                $title,
1218
                $content,
1219
                $startDate,
1220
                $endDate,
1221
                $firstPath,
1222
                $firstName,
1223
                $firstSize,
1224
                $firstComment,
1225
                $allDay
1226
            );
1227
1228
            $this->course->add_resource($export);
1229
        }
1230
    }
1231
1232
    /**
1233
     * Export Announcements (first attachment legacy, all as assets).
1234
     *
1235
     * @param  object             $legacyCourse
1236
     * @param  CourseEntity|null  $courseEntity
1237
     * @param  SessionEntity|null $sessionEntity
1238
     * @param  array<int>         $ids
1239
     * @return void
1240
     */
1241
    private function build_announcements(
1242
        object $legacyCourse,
1243
        ?CourseEntity $courseEntity,
1244
        ?SessionEntity $sessionEntity,
1245
        array $ids
1246
    ): void {
1247
        if (!$courseEntity) {
1248
            return;
1249
        }
1250
1251
        $annRepo = Container::getAnnouncementRepository();
1252
        $qb      = $annRepo->getResourcesByCourse($courseEntity, $sessionEntity);
1253
1254
        if (!empty($ids)) {
1255
            $qb->andWhere('resource.iid IN (:ids)')
1256
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
1257
        }
1258
1259
        /** @var CAnnouncement[] $anns */
1260
        $anns = $qb->getQuery()->getResult();
1261
1262
        /** @var KernelInterface $kernel */
1263
        $kernel       = Container::$container->get('kernel');
1264
        $projectDir   = rtrim($kernel->getProjectDir(), '/');
1265
        $resourceBase = $projectDir . '/var/upload/resource';
1266
1267
        /** @var ResourceNodeRepository $rnRepo */
1268
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
1269
1270
        foreach ($anns as $a) {
1271
            $iid   = (int) $a->getIid();
1272
            $title = (string) $a->getTitle();
1273
            $html  = (string) ($a->getContent() ?? '');
1274
            $date  = $a->getEndDate()?->format('Y-m-d H:i:s') ?? '';
1275
            $email = (bool) $a->getEmailSent();
1276
1277
            $firstPath = $firstName = $firstComment = '';
1278
            $firstSize = 0;
1279
1280
            $attachmentsArr = [];
1281
1282
            /** @var CAnnouncementAttachment $att */
1283
            foreach ($a->getAttachments() as $att) {
1284
                $relPath  = ltrim((string) $att->getPath(), '/');
1285
                $assetRel = 'upload/announcements/' . $relPath;
1286
1287
                $abs = null;
1288
                $node = $att->getResourceNode();
1289
                if ($node) {
1290
                    $file = $node->getFirstResourceFile();
1291
                    if ($file) {
1292
                        $storedRel = (string) $rnRepo->getFilename($file);
1293
                        if ($storedRel !== '') {
1294
                            $candidate = $resourceBase . $storedRel;
1295
                            if (is_readable($candidate)) {
1296
                                $abs = $candidate;
1297
                            }
1298
                        }
1299
                    }
1300
                }
1301
1302
                if ($abs) {
1303
                    $this->tryAddAsset($assetRel, $abs, (int) $att->getSize());
1304
                } else {
1305
                    error_log('COURSE_BUILD: announcement attachment not found (iid=' . (int) $att->getIid() . ')');
1306
                }
1307
1308
                $attachmentsArr[] = [
1309
                    'path'          => $relPath,
1310
                    'filename'      => (string) $att->getFilename(),
1311
                    'size'          => (int) $att->getSize(),
1312
                    'comment'       => (string) ($att->getComment() ?? ''),
1313
                    'asset_relpath' => $assetRel,
1314
                ];
1315
1316
                if ($firstName === '') {
1317
                    $firstPath    = $relPath;
1318
                    $firstName    = (string) $att->getFilename();
1319
                    $firstSize    = (int) $att->getSize();
1320
                    $firstComment = (string) ($att->getComment() ?? '');
1321
                }
1322
            }
1323
1324
            $payload = [
1325
                'title'               => $title,
1326
                'content'             => $html,
1327
                'date'                => $date,
1328
                'display_order'       => 0,
1329
                'email_sent'          => $email ? 1 : 0,
1330
                'attachment_path'     => $firstPath,
1331
                'attachment_filename' => $firstName,
1332
                'attachment_size'     => $firstSize,
1333
                'attachment_comment'  => $firstComment,
1334
                'attachments'         => $attachmentsArr,
1335
            ];
1336
1337
            $legacyCourse->resources[RESOURCE_ANNOUNCEMENT][$iid] =
1338
                $this->mkLegacyItem(RESOURCE_ANNOUNCEMENT, $iid, $payload, ['attachments']);
1339
        }
1340
    }
1341
1342
    /**
1343
     * Register an asset to be packed into the export ZIP.
1344
     *
1345
     * @param  string $relPath Relative path inside the ZIP
1346
     * @param  string $absPath Absolute filesystem path
1347
     * @param  int    $size
1348
     * @return void
1349
     */
1350
    private function addAsset(string $relPath, string $absPath, int $size = 0): void
1351
    {
1352
        if (!isset($this->course->resources['asset']) || !is_array($this->course->resources['asset'])) {
1353
            $this->course->resources['asset'] = [];
1354
        }
1355
        $this->course->resources['asset'][$relPath] = [
1356
            'abs'  => $absPath,
1357
            'size' => $size,
1358
        ];
1359
    }
1360
1361
    /**
1362
     * Try to add an asset only if file exists.
1363
     *
1364
     * @param  string $relPath
1365
     * @param  string $absPath
1366
     * @param  int    $size
1367
     * @return void
1368
     */
1369
    private function tryAddAsset(string $relPath, string $absPath, int $size = 0): void
1370
    {
1371
        if (is_file($absPath) && is_readable($absPath)) {
1372
            $this->addAsset($relPath, $absPath, $size);
1373
        } else {
1374
            error_log('COURSE_BUILD: asset missing: ' . $absPath);
1375
        }
1376
    }
1377
1378
    /**
1379
     * Export Surveys; returns needed Question IDs for follow-up export.
1380
     *
1381
     * @param  object             $legacyCourse
1382
     * @param  CourseEntity|null  $courseEntity
1383
     * @param  SessionEntity|null $sessionEntity
1384
     * @param  array<int>         $surveyIds
1385
     * @return array<int>
1386
     */
1387
    private function build_surveys(
1388
        object $legacyCourse,
1389
        ?CourseEntity $courseEntity,
1390
        ?SessionEntity $sessionEntity,
1391
        array $surveyIds
1392
    ): array {
1393
        if (!$courseEntity) {
1394
            return [];
1395
        }
1396
1397
        $qb = $this->em->createQueryBuilder()
1398
            ->select('s')
1399
            ->from(CSurvey::class, 's')
1400
            ->innerJoin('s.resourceNode', 'rn')
1401
            ->leftJoin('rn.resourceLinks', 'links')
1402
            ->andWhere('links.course = :course')->setParameter('course', $courseEntity)
1403
            ->andWhere($sessionEntity
1404
                ? '(links.session IS NULL OR links.session = :session)'
1405
                : 'links.session IS NULL'
1406
            )
1407
            ->andWhere('links.deletedAt IS NULL')
1408
            ->andWhere('links.endVisibilityAt IS NULL');
1409
1410
        if ($sessionEntity) {
1411
            $qb->setParameter('session', $sessionEntity);
1412
        }
1413
        if (!empty($surveyIds)) {
1414
            $qb->andWhere('s.iid IN (:ids)')->setParameter('ids', array_map('intval', $surveyIds));
1415
        }
1416
1417
        /** @var CSurvey[] $surveys */
1418
        $surveys = $qb->getQuery()->getResult();
1419
1420
        $neededQuestionIds = [];
1421
1422
        foreach ($surveys as $s) {
1423
            $iid  = (int) $s->getIid();
1424
            $qIds = [];
1425
1426
            foreach ($s->getQuestions() as $q) {
1427
                /** @var CSurveyQuestion $q */
1428
                $qid = (int) $q->getIid();
1429
                $qIds[] = $qid;
1430
                $neededQuestionIds[$qid] = true;
1431
            }
1432
1433
            $payload = [
1434
                'code'                    => (string) ($s->getCode() ?? ''),
1435
                'title'                   => (string) $s->getTitle(),
1436
                'subtitle'                => (string) ($s->getSubtitle() ?? ''),
1437
                'author'                  => '',
1438
                'lang'                    => (string) ($s->getLang() ?? ''),
1439
                'avail_from'              => $s->getAvailFrom()?->format('Y-m-d H:i:s'),
1440
                'avail_till'              => $s->getAvailTill()?->format('Y-m-d H:i:s'),
1441
                'is_shared'               => (string) ($s->getIsShared() ?? '0'),
1442
                'template'                => (string) ($s->getTemplate() ?? 'template'),
1443
                'intro'                   => (string) ($s->getIntro() ?? ''),
1444
                'surveythanks'            => (string) ($s->getSurveythanks() ?? ''),
1445
                'creation_date'           => $s->getCreationDate()?->format('Y-m-d H:i:s') ?: date('Y-m-d H:i:s'),
1446
                'invited'                 => (int) $s->getInvited(),
1447
                'answered'                => (int) $s->getAnswered(),
1448
                'invite_mail'             => (string) $s->getInviteMail(),
1449
                'reminder_mail'           => (string) $s->getReminderMail(),
1450
                'mail_subject'            => (string) $s->getMailSubject(),
1451
                'anonymous'               => (string) $s->getAnonymous(),
1452
                'shuffle'                 => (bool) $s->getShuffle(),
1453
                'one_question_per_page'   => (bool) $s->getOneQuestionPerPage(),
1454
                'visible_results'         => $s->getVisibleResults(),
1455
                'display_question_number' => (bool) $s->isDisplayQuestionNumber(),
1456
                'survey_type'             => (int) $s->getSurveyType(),
1457
                'show_form_profile'       => (int) $s->getShowFormProfile(),
1458
                'form_fields'             => (string) $s->getFormFields(),
1459
                'duration'                => $s->getDuration(),
1460
                'question_ids'            => $qIds,
1461
                'survey_id'               => $iid,
1462
            ];
1463
1464
            $legacyCourse->resources[RESOURCE_SURVEY][$iid] =
1465
                $this->mkLegacyItem(RESOURCE_SURVEY, $iid, $payload);
1466
1467
            error_log('COURSE_BUILD: SURVEY iid=' . $iid . ' qids=[' . implode(',', $qIds) . ']');
1468
        }
1469
1470
        return array_keys($neededQuestionIds);
1471
    }
1472
1473
    /**
1474
     * Export Survey Questions (answers promoted at top level).
1475
     *
1476
     * @param  object             $legacyCourse
1477
     * @param  CourseEntity|null  $courseEntity
1478
     * @param  SessionEntity|null $sessionEntity
1479
     * @param  array<int>         $questionIds
1480
     * @return void
1481
     */
1482
    private function build_survey_questions(
1483
        object $legacyCourse,
1484
        ?CourseEntity $courseEntity,
1485
        ?SessionEntity $sessionEntity,
1486
        array $questionIds
1487
    ): void {
1488
        if (!$courseEntity) {
1489
            return;
1490
        }
1491
1492
        $qb = $this->em->createQueryBuilder()
1493
            ->select('q', 's')
1494
            ->from(CSurveyQuestion::class, 'q')
1495
            ->innerJoin('q.survey', 's')
1496
            ->innerJoin('s.resourceNode', 'rn')
1497
            ->leftJoin('rn.resourceLinks', 'links')
1498
            ->andWhere('links.course = :course')->setParameter('course', $courseEntity)
1499
            ->andWhere($sessionEntity
1500
                ? '(links.session IS NULL OR links.session = :session)'
1501
                : 'links.session IS NULL'
1502
            )
1503
            ->andWhere('links.deletedAt IS NULL')
1504
            ->andWhere('links.endVisibilityAt IS NULL')
1505
            ->orderBy('s.iid', 'ASC')
1506
            ->addOrderBy('q.sort', 'ASC');
1507
1508
        if ($sessionEntity) {
1509
            $qb->setParameter('session', $sessionEntity);
1510
        }
1511
        if (!empty($questionIds)) {
1512
            $qb->andWhere('q.iid IN (:ids)')->setParameter('ids', array_map('intval', $questionIds));
1513
        }
1514
1515
        /** @var CSurveyQuestion[] $questions */
1516
        $questions = $qb->getQuery()->getResult();
1517
1518
        $exported = 0;
1519
1520
        foreach ($questions as $q) {
1521
            $qid = (int) $q->getIid();
1522
            $sid = (int) $q->getSurvey()->getIid();
1523
1524
            $answers = [];
1525
            foreach ($q->getOptions() as $opt) {
1526
                /** @var CSurveyQuestionOption $opt */
1527
                $answers[] = [
1528
                    'option_text' => (string) $opt->getOptionText(),
1529
                    'sort'        => (int) $opt->getSort(),
1530
                    'value'       => (int) $opt->getValue(),
1531
                ];
1532
            }
1533
1534
            $payload = [
1535
                'survey_id'               => $sid,
1536
                'survey_question'         => (string) $q->getSurveyQuestion(),
1537
                'survey_question_comment' => (string) ($q->getSurveyQuestionComment() ?? ''),
1538
                'type'                    => (string) $q->getType(),
1539
                'display'                 => (string) $q->getDisplay(),
1540
                'sort'                    => (int) $q->getSort(),
1541
                'shared_question_id'      => $q->getSharedQuestionId(),
1542
                'max_value'               => $q->getMaxValue(),
1543
                'is_required'             => (bool) $q->isMandatory(),
1544
                'answers'                 => $answers,
1545
            ];
1546
1547
            $legacyCourse->resources[RESOURCE_SURVEYQUESTION][$qid] =
1548
                $this->mkLegacyItem(RESOURCE_SURVEYQUESTION, $qid, $payload, ['answers']);
1549
1550
            $exported++;
1551
            error_log('COURSE_BUILD: SURVEY_Q qid=' . $qid . ' survey=' . $sid . ' answers=' . count($answers));
1552
        }
1553
1554
        error_log('COURSE_BUILD: survey questions exported=' . $exported);
1555
    }
1556
1557
    /**
1558
     * Export Quizzes and return required Question IDs.
1559
     *
1560
     * @param  object             $legacyCourse
1561
     * @param  CourseEntity|null  $courseEntity
1562
     * @param  SessionEntity|null $sessionEntity
1563
     * @param  array<int>         $quizIds
1564
     * @return array<int>
1565
     */
1566
    private function build_quizzes(
1567
        object $legacyCourse,
1568
        ?CourseEntity $courseEntity,
1569
        ?SessionEntity $sessionEntity,
1570
        array $quizIds
1571
    ): array {
1572
        if (!$courseEntity) {
1573
            return [];
1574
        }
1575
1576
        $qb = $this->em->createQueryBuilder()
1577
            ->select('q')
1578
            ->from(CQuiz::class, 'q')
1579
            ->innerJoin('q.resourceNode', 'rn')
1580
            ->leftJoin('rn.resourceLinks', 'links')
1581
            ->andWhere('links.course = :course')->setParameter('course', $courseEntity)
1582
            ->andWhere($sessionEntity
1583
                ? '(links.session IS NULL OR links.session = :session)'
1584
                : 'links.session IS NULL'
1585
            )
1586
            ->andWhere('links.deletedAt IS NULL')
1587
            ->andWhere('links.endVisibilityAt IS NULL');
1588
1589
        if ($sessionEntity) {
1590
            $qb->setParameter('session', $sessionEntity);
1591
        }
1592
        if (!empty($quizIds)) {
1593
            $qb->andWhere('q.iid IN (:ids)')->setParameter('ids', array_map('intval', $quizIds));
1594
        }
1595
1596
        /** @var CQuiz[] $quizzes */
1597
        $quizzes = $qb->getQuery()->getResult();
1598
        $neededQuestionIds = [];
1599
1600
        foreach ($quizzes as $quiz) {
1601
            $iid = (int) $quiz->getIid();
1602
1603
            $payload = [
1604
                'title'                   => (string) $quiz->getTitle(),
1605
                'description'             => (string) ($quiz->getDescription() ?? ''),
1606
                'type'                    => (int) $quiz->getType(),
1607
                'random'                  => (int) $quiz->getRandom(),
1608
                'random_answers'          => (bool) $quiz->getRandomAnswers(),
1609
                'results_disabled'        => (int) $quiz->getResultsDisabled(),
1610
                'max_attempt'             => (int) $quiz->getMaxAttempt(),
1611
                'feedback_type'           => (int) $quiz->getFeedbackType(),
1612
                'expired_time'            => (int) $quiz->getExpiredTime(),
1613
                'review_answers'          => (int) $quiz->getReviewAnswers(),
1614
                'random_by_category'      => (int) $quiz->getRandomByCategory(),
1615
                'text_when_finished'      => (string) ($quiz->getTextWhenFinished() ?? ''),
1616
                'text_when_finished_failure' => (string) ($quiz->getTextWhenFinishedFailure() ?? ''),
1617
                'display_category_name'   => (int) $quiz->getDisplayCategoryName(),
1618
                'save_correct_answers'    => (int) ($quiz->getSaveCorrectAnswers() ?? 0),
1619
                'propagate_neg'           => (int) $quiz->getPropagateNeg(),
1620
                'hide_question_title'     => (bool) $quiz->isHideQuestionTitle(),
1621
                'hide_question_number'    => (int) $quiz->getHideQuestionNumber(),
1622
                'question_selection_type' => (int) ($quiz->getQuestionSelectionType() ?? 0),
1623
                'access_condition'        => (string) ($quiz->getAccessCondition() ?? ''),
1624
                'pass_percentage'         => $quiz->getPassPercentage(),
1625
                'start_time'              => $quiz->getStartTime()?->format('Y-m-d H:i:s'),
1626
                'end_time'                => $quiz->getEndTime()?->format('Y-m-d H:i:s'),
1627
                'question_ids'            => [],
1628
                'question_orders'         => [],
1629
            ];
1630
1631
            $rels = $this->em->createQueryBuilder()
1632
                ->select('rel', 'qq')
1633
                ->from(CQuizRelQuestion::class, 'rel')
1634
                ->innerJoin('rel.question', 'qq')
1635
                ->andWhere('rel.quiz = :quiz')
1636
                ->setParameter('quiz', $quiz)
1637
                ->orderBy('rel.questionOrder', 'ASC')
1638
                ->getQuery()->getResult();
1639
1640
            foreach ($rels as $rel) {
1641
                $qid = (int) $rel->getQuestion()->getIid();
1642
                $payload['question_ids'][]    = $qid;
1643
                $payload['question_orders'][] = (int) $rel->getQuestionOrder();
1644
                $neededQuestionIds[$qid] = true;
1645
            }
1646
1647
            $legacyCourse->resources[RESOURCE_QUIZ][$iid] =
1648
                $this->mkLegacyItem(
1649
                    RESOURCE_QUIZ,
1650
                    $iid,
1651
                    $payload,
1652
                    ['question_ids', 'question_orders']
1653
                );
1654
        }
1655
1656
        error_log(
1657
            'COURSE_BUILD: build_quizzes done; total=' . count($quizzes)
1658
        );
1659
1660
        return array_keys($neededQuestionIds);
1661
    }
1662
1663
    /**
1664
     * Safe count helper for mixed values.
1665
     *
1666
     * @param  mixed $v
1667
     * @return int
1668
     */
1669
    private function safeCount(mixed $v): int
1670
    {
1671
        return (is_array($v) || $v instanceof \Countable) ? \count($v) : 0;
1672
    }
1673
1674
    /**
1675
     * Export Quiz Questions (answers and options promoted).
1676
     *
1677
     * @param  object             $legacyCourse
1678
     * @param  CourseEntity|null  $courseEntity
1679
     * @param  SessionEntity|null $sessionEntity
1680
     * @param  array<int>         $questionIds
1681
     * @return void
1682
     */
1683
    private function build_quiz_questions(
1684
        object $legacyCourse,
1685
        ?CourseEntity $courseEntity,
1686
        ?SessionEntity $sessionEntity,
1687
        array $questionIds
1688
    ): void {
1689
        if (!$courseEntity) {
1690
            return;
1691
        }
1692
1693
        error_log('COURSE_BUILD: build_quiz_questions start ids=' . json_encode(array_values($questionIds)));
1694
        error_log('COURSE_BUILD: build_quiz_questions exported=' . $this->safeCount($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? 0));
1695
1696
        $qb = $this->em->createQueryBuilder()
1697
            ->select('qq')
1698
            ->from(CQuizQuestion::class, 'qq')
1699
            ->innerJoin('qq.resourceNode', 'qrn')
1700
            ->leftJoin('qrn.resourceLinks', 'qlinks')
1701
            ->andWhere('qlinks.course = :course')->setParameter('course', $courseEntity)
1702
            ->andWhere($sessionEntity
1703
                ? '(qlinks.session IS NULL OR qlinks.session = :session)'
1704
                : 'qlinks.session IS NULL'
1705
            )
1706
            ->andWhere('qlinks.deletedAt IS NULL')
1707
            ->andWhere('qlinks.endVisibilityAt IS NULL');
1708
1709
        if ($sessionEntity) {
1710
            $qb->setParameter('session', $sessionEntity);
1711
        }
1712
        if (!empty($questionIds)) {
1713
            $qb->andWhere('qq.iid IN (:ids)')->setParameter('ids', array_map('intval', $questionIds));
1714
        }
1715
1716
        /** @var CQuizQuestion[] $questions */
1717
        $questions = $qb->getQuery()->getResult();
1718
1719
        error_log('COURSE_BUILD: build_quiz_questions start ids=' . json_encode(array_values($questionIds)));
1720
        error_log('COURSE_BUILD: build_quiz_questions exported=' . $this->safeCount($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? 0));
1721
1722
        $this->exportQuestionsWithAnswers($legacyCourse, $questions);
1723
    }
1724
1725
    /**
1726
     * Internal exporter for quiz questions + answers (+ options for MATF type).
1727
     *
1728
     * @param  object                $legacyCourse
1729
     * @param  array<int,CQuizQuestion> $questions
1730
     * @return void
1731
     */
1732
    private function exportQuestionsWithAnswers(object $legacyCourse, array $questions): void
1733
    {
1734
        foreach ($questions as $q) {
1735
            $qid = (int) $q->getIid();
1736
1737
            $payload = [
1738
                'question'       => (string) $q->getQuestion(),
1739
                'description'    => (string) ($q->getDescription() ?? ''),
1740
                'ponderation'    => (float) $q->getPonderation(),
1741
                'position'       => (int) $q->getPosition(),
1742
                'type'           => (int) $q->getType(),
1743
                'quiz_type'      => (int) $q->getType(),
1744
                'picture'        => (string) ($q->getPicture() ?? ''),
1745
                'level'          => (int) $q->getLevel(),
1746
                'extra'          => (string) ($q->getExtra() ?? ''),
1747
                'feedback'       => (string) ($q->getFeedback() ?? ''),
1748
                'question_code'  => (string) ($q->getQuestionCode() ?? ''),
1749
                'mandatory'      => (int) $q->getMandatory(),
1750
                'duration'       => $q->getDuration(),
1751
                'parent_media_id'=> $q->getParentMediaId(),
1752
                'answers'        => [],
1753
            ];
1754
1755
            $ans = $this->em->createQueryBuilder()
1756
                ->select('a')
1757
                ->from(CQuizAnswer::class, 'a')
1758
                ->andWhere('a.question = :q')->setParameter('q', $q)
1759
                ->orderBy('a.position', 'ASC')
1760
                ->getQuery()->getResult();
1761
1762
            foreach ($ans as $a) {
1763
                $payload['answers'][] = [
1764
                    'id'                  => (int) $a->getIid(),
1765
                    'answer'              => (string) $a->getAnswer(),
1766
                    'comment'             => (string) ($a->getComment() ?? ''),
1767
                    'ponderation'         => (float) $a->getPonderation(),
1768
                    'position'            => (int) $a->getPosition(),
1769
                    'hotspot_coordinates' => $a->getHotspotCoordinates(),
1770
                    'hotspot_type'        => $a->getHotspotType(),
1771
                    'correct'             => $a->getCorrect(),
1772
                ];
1773
            }
1774
1775
            if (defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === (int) $q->getType()) {
1776
                $opts = $this->em->createQueryBuilder()
1777
                    ->select('o')
1778
                    ->from(CQuizQuestionOption::class, 'o')
1779
                    ->andWhere('o.question = :q')->setParameter('q', $q)
1780
                    ->orderBy('o.position', 'ASC')
1781
                    ->getQuery()->getResult();
1782
1783
                $payload['question_options'] = array_map(static fn($o) => [
1784
                    'id'       => (int) $o->getIid(),
1785
                    'name'     => (string) $o->getTitle(),
1786
                    'position' => (int) $o->getPosition(),
1787
                ], $opts);
1788
            }
1789
1790
            $legacyCourse->resources[RESOURCE_QUIZQUESTION][$qid] =
1791
                $this->mkLegacyItem(RESOURCE_QUIZQUESTION, $qid, $payload, ['answers', 'question_options']);
1792
1793
            error_log('COURSE_BUILD: QQ qid=' . $qid .
1794
                ' quiz_type=' . ($legacyCourse->resources[RESOURCE_QUIZQUESTION][$qid]->quiz_type ?? 'missing') .
1795
                ' answers=' . count($legacyCourse->resources[RESOURCE_QUIZQUESTION][$qid]->answers ?? [])
1796
            );
1797
        }
1798
    }
1799
1800
    /**
1801
     * Export Link category as legacy item.
1802
     *
1803
     * @param  CLinkCategory $category
1804
     * @return void
1805
     */
1806
    private function build_link_category(CLinkCategory $category): void
1807
    {
1808
        $id = (int) $category->getIid();
1809
        if ($id <= 0) {
1810
            return;
1811
        }
1812
1813
        $payload = [
1814
            'title'         => (string) $category->getTitle(),
1815
            'description'   => (string) ($category->getDescription() ?? ''),
1816
            'category_title'=> (string) $category->getTitle(),
1817
        ];
1818
1819
        $this->course->resources[RESOURCE_LINKCATEGORY][$id] =
1820
            $this->mkLegacyItem(RESOURCE_LINKCATEGORY, $id, $payload);
1821
    }
1822
1823
    /**
1824
     * Export Links (and their categories once).
1825
     *
1826
     * @param  object             $legacyCourse
1827
     * @param  CourseEntity|null  $courseEntity
1828
     * @param  SessionEntity|null $sessionEntity
1829
     * @param  array<int>         $ids
1830
     * @return void
1831
     */
1832
    private function build_links(
1833
        object $legacyCourse,
1834
        ?CourseEntity $courseEntity,
1835
        ?SessionEntity $sessionEntity,
1836
        array $ids
1837
    ): void {
1838
        if (!$courseEntity instanceof CourseEntity) {
1839
            return;
1840
        }
1841
1842
        $linkRepo = Container::getLinkRepository();
1843
        $catRepo  = Container::getLinkCategoryRepository();
1844
1845
        $qb = $linkRepo->getResourcesByCourse($courseEntity, $sessionEntity);
1846
1847
        if (!empty($ids)) {
1848
            $qb->andWhere('resource.iid IN (:ids)')
1849
                ->setParameter('ids', array_values(array_unique(array_map('intval', $ids))));
1850
        }
1851
1852
        /** @var CLink[] $links */
1853
        $links = $qb->getQuery()->getResult();
1854
1855
        $exportedCats = [];
1856
1857
        foreach ($links as $link) {
1858
            $iid   = (int) $link->getIid();
1859
            $title = (string) $link->getTitle();
1860
            $url   = (string) $link->getUrl();
1861
            $desc  = (string) ($link->getDescription() ?? '');
1862
            $tgt   = (string) ($link->getTarget() ?? '');
1863
1864
            $cat   = $link->getCategory();
1865
            $catId = (int) ($cat?->getIid() ?? 0);
1866
1867
            if ($catId > 0 && !isset($exportedCats[$catId])) {
1868
                $this->build_link_category($cat);
1869
                $exportedCats[$catId] = true;
1870
            }
1871
1872
            $payload = [
1873
                'title'       => $title !== '' ? $title : $url,
1874
                'url'         => $url,
1875
                'description' => $desc,
1876
                'target'      => $tgt,
1877
                'category_id' => $catId,
1878
                'on_homepage' => false,
1879
            ];
1880
1881
            $legacyCourse->resources[RESOURCE_LINK][$iid] =
1882
                $this->mkLegacyItem(RESOURCE_LINK, $iid, $payload);
1883
        }
1884
    }
1885
1886
    /**
1887
     * Format DateTime as string "Y-m-d H:i:s".
1888
     *
1889
     * @param  \DateTimeInterface|null $dt
1890
     * @return string
1891
     */
1892
    private function fmtDate(?\DateTimeInterface $dt): string
1893
    {
1894
        return $dt ? $dt->format('Y-m-d H:i:s') : '';
1895
    }
1896
1897
    /**
1898
     * Create a legacy item object, promoting selected array keys to top-level.
1899
     *
1900
     * @param  string             $type
1901
     * @param  int                $sourceId
1902
     * @param  array|object       $obj
1903
     * @param  array<int,string>  $arrayKeysToPromote
1904
     * @return \stdClass
1905
     */
1906
    private function mkLegacyItem(string $type, int $sourceId, array|object $obj, array $arrayKeysToPromote = []): \stdClass
1907
    {
1908
        $o = new \stdClass();
1909
        $o->type           = $type;
1910
        $o->source_id      = $sourceId;
1911
        $o->destination_id = -1;
1912
        $o->has_obj        = true;
1913
        $o->obj            = (object) $obj;
1914
1915
        foreach ((array) $obj as $k => $v) {
1916
            if (is_scalar($v) || $v === null) {
1917
                if (!property_exists($o, $k)) {
1918
                    $o->$k = $v;
1919
                }
1920
            }
1921
        }
1922
        foreach ($arrayKeysToPromote as $k) {
1923
            if (isset($obj[$k]) && is_array($obj[$k])) {
1924
                $o->$k = $obj[$k];
1925
            }
1926
        }
1927
1928
        // Ensure "name" for restorer display
1929
        if (!isset($o->name) || $o->name === '' || $o->name === null) {
1930
            if (isset($obj['name']) && $obj['name'] !== '') {
1931
                $o->name = (string) $obj['name'];
1932
            } elseif (isset($obj['title']) && $obj['title'] !== '') {
1933
                $o->name = (string) $obj['title'];
1934
            } else {
1935
                $o->name = $type . ' ' . $sourceId;
1936
            }
1937
        }
1938
1939
        return $o;
1940
    }
1941
1942
    /**
1943
     * Build an id filter closure.
1944
     *
1945
     * @param  array<int> $idsFilter
1946
     * @return \Closure(int):bool
1947
     */
1948
    private function makeIdFilter(array $idsFilter): \Closure
1949
    {
1950
        if (empty($idsFilter)) {
1951
            return static fn(int $id): bool => true;
1952
        }
1953
        $set = array_fill_keys(array_map('intval', $idsFilter), true);
1954
        return static fn(int $id): bool => isset($set[$id]);
1955
    }
1956
1957
    /**
1958
     * Export Tool intro (tool -> intro text) for visible tools.
1959
     *
1960
     * @param  object             $legacyCourse
1961
     * @param  CourseEntity|null  $courseEntity
1962
     * @param  SessionEntity|null $sessionEntity
1963
     * @return void
1964
     */
1965
    private function build_tool_intro(
1966
        object $legacyCourse,
1967
        ?CourseEntity $courseEntity,
1968
        ?SessionEntity $sessionEntity
1969
    ): void {
1970
        if (!$courseEntity instanceof CourseEntity) {
1971
            return;
1972
        }
1973
1974
        $repo = $this->em->getRepository(CToolIntro::class);
1975
1976
        $qb = $repo->createQueryBuilder('ti')
1977
            ->innerJoin('ti.courseTool', 'ct')
1978
            ->andWhere('ct.course = :course')
1979
            ->setParameter('course', $courseEntity);
1980
1981
        if ($sessionEntity) {
1982
            $qb->andWhere('ct.session = :session')->setParameter('session', $sessionEntity);
1983
        } else {
1984
            $qb->andWhere('ct.session IS NULL');
1985
        }
1986
1987
        $qb->andWhere('ct.visibility = :vis')->setParameter('vis', true);
1988
1989
        /** @var CToolIntro[] $intros */
1990
        $intros = $qb->getQuery()->getResult();
1991
1992
        foreach ($intros as $intro) {
1993
            $ctool    = $intro->getCourseTool();       // CTool
1994
            $titleKey = (string) $ctool->getTitle();   // e.g. 'documents', 'forum'
1995
            if ($titleKey === '') {
1996
                continue;
1997
            }
1998
1999
            $payload = [
2000
                'id'         => $titleKey,
2001
                'intro_text' => (string) $intro->getIntroText(),
2002
            ];
2003
2004
            // Use 0 as source_id (unused by restore)
2005
            $legacyCourse->resources[RESOURCE_TOOL_INTRO][$titleKey] =
2006
                $this->mkLegacyItem(RESOURCE_TOOL_INTRO, 0, $payload);
2007
        }
2008
    }
2009
2010
    /**
2011
     * Export Forum categories.
2012
     *
2013
     * @param  object             $legacyCourse
2014
     * @param  CourseEntity       $courseEntity
2015
     * @param  SessionEntity|null $sessionEntity
2016
     * @param  array<int>         $ids
2017
     * @return void
2018
     */
2019
    private function build_forum_category(
2020
        object $legacyCourse,
2021
        CourseEntity $courseEntity,
2022
        ?SessionEntity $sessionEntity,
2023
        array $ids
2024
    ): void {
2025
        $repo = Container::getForumCategoryRepository();
2026
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
2027
        $categories = $qb->getQuery()->getResult();
2028
2029
        $keep = $this->makeIdFilter($ids);
2030
2031
        foreach ($categories as $cat) {
2032
            /** @var CForumCategory $cat */
2033
            $id = (int) $cat->getIid();
2034
            if (!$keep($id)) {
2035
                continue;
2036
            }
2037
2038
            $payload = [
2039
                'title'       => (string) $cat->getTitle(),
2040
                'description' => (string) ($cat->getCatComment() ?? ''),
2041
                'cat_title'   => (string) $cat->getTitle(),
2042
                'cat_comment' => (string) ($cat->getCatComment() ?? ''),
2043
            ];
2044
2045
            $legacyCourse->resources[RESOURCE_FORUMCATEGORY][$id] =
2046
                $this->mkLegacyItem(RESOURCE_FORUMCATEGORY, $id, $payload);
2047
        }
2048
    }
2049
2050
    /**
2051
     * Export Forums.
2052
     *
2053
     * @param  object             $legacyCourse
2054
     * @param  CourseEntity       $courseEntity
2055
     * @param  SessionEntity|null $sessionEntity
2056
     * @param  array<int>         $ids
2057
     * @return void
2058
     */
2059
    private function build_forums(
2060
        object $legacyCourse,
2061
        CourseEntity $courseEntity,
2062
        ?SessionEntity $sessionEntity,
2063
        array $ids
2064
    ): void {
2065
        $repo = Container::getForumRepository();
2066
        $qb   = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
2067
        $forums = $qb->getQuery()->getResult();
2068
2069
        $keep = $this->makeIdFilter($ids);
2070
2071
        foreach ($forums as $f) {
2072
            /** @var CForum $f */
2073
            $id = (int) $f->getIid();
2074
            if (!$keep($id)) {
2075
                continue;
2076
            }
2077
2078
            $payload = [
2079
                'title'                      => (string) $f->getTitle(),
2080
                'description'                => (string) ($f->getForumComment() ?? ''),
2081
                'forum_title'                => (string) $f->getTitle(),
2082
                'forum_comment'              => (string) ($f->getForumComment() ?? ''),
2083
                'forum_category'             => (int) ($f->getForumCategory()?->getIid() ?? 0),
2084
                'allow_anonymous'            => (int) ($f->getAllowAnonymous() ?? 0),
2085
                'allow_edit'                 => (int) ($f->getAllowEdit() ?? 0),
2086
                'approval_direct_post'       => (string) ($f->getApprovalDirectPost() ?? '0'),
2087
                'allow_attachments'          => (int) ($f->getAllowAttachments() ?? 1),
2088
                'allow_new_threads'          => (int) ($f->getAllowNewThreads() ?? 1),
2089
                'default_view'               => (string) ($f->getDefaultView() ?? 'flat'),
2090
                'forum_of_group'             => (string) ($f->getForumOfGroup() ?? '0'),
2091
                'forum_group_public_private' => (string) ($f->getForumGroupPublicPrivate() ?? 'public'),
2092
                'moderated'                  => (int) ($f->isModerated() ? 1 : 0),
2093
                'start_time'                 => $this->fmtDate($f->getStartTime()),
2094
                'end_time'                   => $this->fmtDate($f->getEndTime()),
2095
            ];
2096
2097
            $legacyCourse->resources[RESOURCE_FORUM][$id] =
2098
                $this->mkLegacyItem(RESOURCE_FORUM, $id, $payload);
2099
        }
2100
    }
2101
2102
    /**
2103
     * Export Forum threads.
2104
     *
2105
     * @param  object             $legacyCourse
2106
     * @param  CourseEntity       $courseEntity
2107
     * @param  SessionEntity|null $sessionEntity
2108
     * @param  array<int>         $ids
2109
     * @return void
2110
     */
2111
    private function build_forum_topics(
2112
        object $legacyCourse,
2113
        CourseEntity $courseEntity,
2114
        ?SessionEntity $sessionEntity,
2115
        array $ids
2116
    ): void {
2117
        $repo    = Container::getForumThreadRepository();
2118
        $qb      = $repo->getResourcesByCourse($courseEntity, $sessionEntity);
2119
        $threads = $qb->getQuery()->getResult();
2120
2121
        $keep = $this->makeIdFilter($ids);
2122
2123
        foreach ($threads as $t) {
2124
            /** @var CForumThread $t */
2125
            $id = (int) $t->getIid();
2126
            if (!$keep($id)) {
2127
                continue;
2128
            }
2129
2130
            $payload = [
2131
                'title'                => (string) $t->getTitle(),
2132
                'thread_title'         => (string) $t->getTitle(),
2133
                'title_qualify'        => (string) ($t->getThreadTitleQualify() ?? ''),
2134
                'topic_poster_name'    => (string) ($t->getUser()?->getUsername() ?? ''),
2135
                'forum_id'             => (int) ($t->getForum()?->getIid() ?? 0),
2136
                'thread_date'          => $this->fmtDate($t->getThreadDate()),
2137
                'thread_sticky'        => (int) ($t->getThreadSticky() ? 1 : 0),
2138
                'thread_title_qualify' => (string) ($t->getThreadTitleQualify() ?? ''),
2139
                'thread_qualify_max'   => (float) $t->getThreadQualifyMax(),
2140
                'thread_weight'        => (float) $t->getThreadWeight(),
2141
                'thread_peer_qualify'  => (int) ($t->isThreadPeerQualify() ? 1 : 0),
2142
            ];
2143
2144
            $legacyCourse->resources[RESOURCE_FORUMTOPIC][$id] =
2145
                $this->mkLegacyItem(RESOURCE_FORUMTOPIC, $id, $payload);
2146
        }
2147
    }
2148
2149
    /**
2150
     * Export first post for each thread as topic root post.
2151
     *
2152
     * @param  object             $legacyCourse
2153
     * @param  CourseEntity       $courseEntity
2154
     * @param  SessionEntity|null $sessionEntity
2155
     * @param  array<int>         $ids
2156
     * @return void
2157
     */
2158
    private function build_forum_posts(
2159
        object $legacyCourse,
2160
        CourseEntity $courseEntity,
2161
        ?SessionEntity $sessionEntity,
2162
        array $ids
2163
    ): void {
2164
        $repoThread = Container::getForumThreadRepository();
2165
        $repoPost   = Container::getForumPostRepository();
2166
2167
        $qb      = $repoThread->getResourcesByCourse($courseEntity, $sessionEntity);
2168
        $threads = $qb->getQuery()->getResult();
2169
2170
        $keep = $this->makeIdFilter($ids);
2171
2172
        foreach ($threads as $t) {
2173
            /** @var CForumThread $t */
2174
            $threadId = (int) $t->getIid();
2175
            if (!$keep($threadId)) {
2176
                continue;
2177
            }
2178
2179
            $first = $repoPost->findOneBy(['thread' => $t], ['postDate' => 'ASC', 'iid' => 'ASC']);
2180
            if (!$first) {
2181
                continue;
2182
            }
2183
2184
            $postId = (int) $first->getIid();
2185
            $titleFromPost = trim((string) $first->getTitle());
2186
            if ($titleFromPost === '') {
2187
                $plain = trim(strip_tags((string) ($first->getPostText() ?? '')));
2188
                $titleFromPost = mb_substr($plain !== '' ? $plain : 'Post', 0, 60);
2189
            }
2190
2191
            $payload = [
2192
                'title'             => $titleFromPost,
2193
                'post_title'        => $titleFromPost,
2194
                'post_text'         => (string) ($first->getPostText() ?? ''),
2195
                'thread_id'         => $threadId,
2196
                'forum_id'          => (int) ($t->getForum()?->getIid() ?? 0),
2197
                'post_notification' => (int) ($first->getPostNotification() ? 1 : 0),
2198
                'visible'           => (int) ($first->getVisible() ? 1 : 0),
2199
                'status'            => (int) ($first->getStatus() ?? CForumPost::STATUS_VALIDATED),
2200
                'post_parent_id'    => (int) ($first->getPostParent()?->getIid() ?? 0),
2201
                'poster_id'         => (int) ($first->getUser()?->getId() ?? 0),
2202
                'text'              => (string) ($first->getPostText() ?? ''),
2203
                'poster_name'       => (string) ($first->getUser()?->getUsername() ?? ''),
2204
                'post_date'         => $this->fmtDate($first->getPostDate()),
2205
            ];
2206
2207
            $legacyCourse->resources[RESOURCE_FORUMPOST][$postId] =
2208
                $this->mkLegacyItem(RESOURCE_FORUMPOST, $postId, $payload);
2209
        }
2210
    }
2211
2212
    /* -----------------------------------------------------------------
2213
     * Documents (Chamilo 2 style)
2214
     * ----------------------------------------------------------------- */
2215
2216
    /**
2217
     * New Chamilo 2 build: CDocumentRepository-based (instead of legacy tables).
2218
     *
2219
     * @param  CourseEntity|null  $course
2220
     * @param  SessionEntity|null $session
2221
     * @param  bool               $withBaseContent
2222
     * @param  array<int>         $idList
2223
     * @return void
2224
     */
2225
    private function build_documents_with_repo(
2226
        ?CourseEntity $course,
2227
        ?SessionEntity $session,
2228
        bool $withBaseContent,
2229
        array $idList = []
2230
    ): void {
2231
        if (!$course instanceof CourseEntity) {
2232
            return;
2233
        }
2234
2235
        $qb = $this->docRepo->getResourcesByCourse(
2236
            $course,
2237
            $session,
2238
            null,
2239
            null,
2240
            true,
2241
            false
2242
        );
2243
2244
        if (!empty($idList)) {
2245
            $qb->andWhere('resource.iid IN (:ids)')
2246
                ->setParameter('ids', array_values(array_unique(array_map('intval', $idList))));
2247
        }
2248
2249
        /** @var CDocument[] $docs */
2250
        $docs = $qb->getQuery()->getResult();
2251
2252
        foreach ($docs as $doc) {
2253
            $node     = $doc->getResourceNode();
2254
            $filetype = $doc->getFiletype(); // 'file'|'folder'|...
2255
            $title    = $doc->getTitle();
2256
            $comment  = $doc->getComment() ?? '';
2257
            $iid      = (int) $doc->getIid();
2258
            $fullPath = $doc->getFullPath();
2259
2260
            // Determine size
2261
            $size = 0;
2262
            if ($filetype === 'folder') {
2263
                $size = $this->docRepo->getFolderSize($node, $course, $session);
2264
            } else {
2265
                /** @var Collection<int,ResourceFile>|null $files */
2266
                $files = $node?->getResourceFiles();
2267
                if ($files && $files->count() > 0) {
2268
                    /** @var ResourceFile $first */
2269
                    $first = $files->first();
2270
                    $size  = (int) $first->getSize();
2271
                }
2272
            }
2273
2274
            $exportDoc = new Document(
2275
                $iid,
2276
                '/' . $fullPath,
2277
                $comment,
2278
                $title,
2279
                $filetype,
2280
                (string) $size
2281
            );
2282
2283
            $this->course->add_resource($exportDoc);
2284
        }
2285
    }
2286
2287
    /**
2288
     * Backward-compatible wrapper for build_documents_with_repo().
2289
     *
2290
     * @param  int    $session_id
2291
     * @param  int    $courseId
2292
     * @param  bool   $withBaseContent
2293
     * @param  array<int> $idList
2294
     * @return void
2295
     */
2296
    public function build_documents(
2297
        int $session_id = 0,
2298
        int $courseId = 0,
2299
        bool $withBaseContent = false,
2300
        array $idList = []
2301
    ): void {
2302
        /** @var CourseEntity|null $course */
2303
        $course = $this->em->getRepository(CourseEntity::class)->find($courseId);
2304
        /** @var SessionEntity|null $session */
2305
        $session = $session_id ? $this->em->getRepository(SessionEntity::class)->find($session_id) : null;
2306
2307
        $this->build_documents_with_repo($course, $session, $withBaseContent, $idList);
2308
    }
2309
}
2310