Passed
Pull Request — master (#7139)
by
unknown
10:00
created

ensureLearningPathSystemFolder()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 17
nc 4
nop 2
dl 0
loc 28
rs 9.0777
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CourseBundle\Repository;
8
9
use Chamilo\CoreBundle\Entity\Course;
10
use Chamilo\CoreBundle\Entity\ResourceFile;
11
use Chamilo\CoreBundle\Entity\ResourceLink;
12
use Chamilo\CoreBundle\Entity\ResourceNode;
13
use Chamilo\CoreBundle\Entity\Session;
14
use Chamilo\CoreBundle\Entity\User;
15
use Chamilo\CoreBundle\Framework\Container;
16
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
17
use Chamilo\CoreBundle\Repository\ResourceRepository;
18
use Chamilo\CourseBundle\Entity\CDocument;
19
use Chamilo\CourseBundle\Entity\CGroup;
20
use Chamilo\CourseBundle\Entity\CLp;
21
use Doctrine\ORM\EntityManagerInterface;
22
use Doctrine\ORM\QueryBuilder;
23
use Doctrine\Persistence\ManagerRegistry;
24
use RuntimeException;
25
use Symfony\Component\HttpFoundation\File\UploadedFile;
26
use Throwable;
27
28
final class CDocumentRepository extends ResourceRepository
29
{
30
    public function __construct(ManagerRegistry $registry)
31
    {
32
        parent::__construct($registry, CDocument::class);
33
    }
34
35
    public function getParent(CDocument $document): ?CDocument
36
    {
37
        $resourceParent = $document->getResourceNode()->getParent();
38
39
        if (null !== $resourceParent) {
40
            return $this->findOneBy(['resourceNode' => $resourceParent->getId()]);
41
        }
42
43
        return null;
44
    }
45
46
    public function getFolderSize(ResourceNode $resourceNode, Course $course, ?Session $session = null): int
47
    {
48
        return $this->getResourceNodeRepository()->getSize($resourceNode, $this->getResourceType(), $course, $session);
49
    }
50
51
    /**
52
     * @return CDocument[]
53
     */
54
    public function findDocumentsByAuthor(int $userId)
55
    {
56
        $qb = $this->createQueryBuilder('d');
57
58
        return $qb
59
            ->innerJoin('d.resourceNode', 'node')
60
            ->innerJoin('node.resourceLinks', 'l')
61
            ->where('l.user = :user')
62
            ->setParameter('user', $userId)
63
            ->getQuery()
64
            ->getResult()
65
        ;
66
    }
67
68
    public function countUserDocuments(User $user, Course $course, ?Session $session = null, ?CGroup $group = null): int
69
    {
70
        $qb = $this->getResourcesByCourseLinkedToUser($user, $course, $session, $group);
71
        $qb->select('count(resource)');
72
        $this->addFileTypeQueryBuilder('file', $qb);
73
74
        return $this->getCount($qb);
75
    }
76
77
    protected function addFileTypeQueryBuilder(string $fileType, ?QueryBuilder $qb = null): QueryBuilder
78
    {
79
        $qb = $this->getOrCreateQueryBuilder($qb);
80
81
        return $qb
82
            ->andWhere('resource.filetype = :filetype')
83
            ->setParameter('filetype', $fileType)
84
        ;
85
    }
86
87
    /**
88
     * Register the original SCORM ZIP under:
89
     *   <course root> / Learning paths / SCORM - {lp_id} - {lp_title} / {lp_title}.zip
90
     */
91
    public function registerScormZip(Course $course, ?Session $session, CLp $lp, UploadedFile $zip): void
92
    {
93
        $em = $this->em();
94
95
        // Ensure "Learning paths" directly under the course resource node
96
        $lpTop = $this->ensureLearningPathSystemFolder($course, $session);
97
98
        // Subfolder per LP
99
        $lpFolderTitle = \sprintf('SCORM - %d - %s', $lp->getIid(), $this->safeTitle($lp->getTitle()));
100
        $lpFolder = $this->ensureFolder(
101
            $course,
102
            $lpTop,
103
            $lpFolderTitle,
104
            ResourceLink::VISIBILITY_DRAFT,
105
            $session
106
        );
107
108
        // ZIP file under the LP folder
109
        $this->createFileInFolder(
110
            $course,
111
            $lpFolder,
112
            $zip,
113
            \sprintf('SCORM ZIP for LP #%d', $lp->getIid()),
114
            ResourceLink::VISIBILITY_DRAFT,
115
            $session
116
        );
117
118
        $em->flush();
119
    }
120
121
    /**
122
     * Remove the LP subfolder "SCORM - {lp_id} - ..." under "Learning paths".
123
     */
124
    public function purgeScormZip(Course $course, CLp $lp): void
125
    {
126
        $em = $this->em();
127
        $prefix = \sprintf('SCORM - %d - ', $lp->getIid());
128
129
        $courseRoot = $course->getResourceNode();
130
        if ($courseRoot) {
131
            // SCORM folder directly under course root
132
            if ($this->tryDeleteFirstFolderByTitlePrefix($courseRoot, $prefix)) {
133
                $em->flush();
134
135
                return;
136
            }
137
138
            // Or under "Learning paths"
139
            $lpTop = $this->findChildNodeByTitle($courseRoot, 'Learning paths');
140
            if ($lpTop && $this->tryDeleteFirstFolderByTitlePrefix($lpTop, $prefix)) {
141
                $em->flush();
142
143
                return;
144
            }
145
        }
146
    }
147
148
    /**
149
     * Try to delete the first child folder whose title starts with $prefix under $parent.
150
     * Returns true if something was removed.
151
     */
152
    private function tryDeleteFirstFolderByTitlePrefix(ResourceNode $parent, string $prefix): bool
153
    {
154
        $em = $this->em();
155
        $qb = $em->createQueryBuilder()
156
            ->select('rn')
157
            ->from(ResourceNode::class, 'rn')
158
            ->where('rn.parent = :parent')
159
            ->andWhere('rn.title LIKE :prefix')
160
            ->setParameters(['parent' => $parent, 'prefix' => $prefix.'%'])
161
            ->setMaxResults(1)
162
        ;
163
164
        /** @var ResourceNode|null $node */
165
        $node = $qb->getQuery()->getOneOrNullResult();
166
        if ($node) {
167
            $em->remove($node);
168
169
            return true;
170
        }
171
172
        return false;
173
    }
174
175
    /**
176
     * Find the course Documents root node.
177
     *
178
     * Primary: parent = course.resourceNode
179
     * Fallback (legacy): parent IS NULL
180
     */
181
    public function getCourseDocumentsRootNode(Course $course): ?ResourceNode
182
    {
183
        $em = $this->em();
184
        $rt = $this->getResourceType();
185
        $courseNode = $course->getResourceNode();
186
187
        if ($courseNode) {
188
            $node = $em->createQuery(
189
                'SELECT rn
190
                   FROM Chamilo\CoreBundle\Entity\ResourceNode rn
191
                   JOIN rn.resourceType rt
192
                   JOIN rn.resourceLinks rl
193
                  WHERE rn.parent = :parent
194
                    AND rt = :rtype
195
                    AND rl.course = :course
196
               ORDER BY rn.id ASC'
197
            )
198
                ->setParameters([
199
                    'parent' => $courseNode,
200
                    'rtype' => $rt,
201
                    'course' => $course,
202
                ])
203
                ->setMaxResults(1)
204
                ->getOneOrNullResult()
205
            ;
206
207
            if ($node) {
208
                return $node;
209
            }
210
        }
211
212
        // Fallback for historical data (Documents root directly under NULL)
213
        return $em->createQuery(
214
            'SELECT rn
215
               FROM Chamilo\CoreBundle\Entity\ResourceNode rn
216
               JOIN rn.resourceType rt
217
               JOIN rn.resourceLinks rl
218
              WHERE rn.parent IS NULL
219
                AND rt = :rtype
220
                AND rl.course = :course
221
           ORDER BY rn.id ASC'
222
        )
223
            ->setParameters(['rtype' => $rt, 'course' => $course])
224
            ->setMaxResults(1)
225
            ->getOneOrNullResult()
226
        ;
227
    }
228
229
    /**
230
     * Ensure the course "Documents" root node exists.
231
     * Now parent = course.resourceNode (so the tool lists it under the course).
232
     */
233
    public function ensureCourseDocumentsRootNode(Course $course): ResourceNode
234
    {
235
        if ($root = $this->getCourseDocumentsRootNode($course)) {
236
            return $root;
237
        }
238
239
        $em = $this->em();
240
        $type = $this->getResourceType();
241
242
        /** @var User|null $user */
243
        $user = api_get_user_entity();
244
245
        $node = new ResourceNode();
246
        $node->setTitle('Documents');
247
        $node->setResourceType($type);
248
        $node->setPublic(false);
249
250
        if ($course->getResourceNode()) {
251
            $node->setParent($course->getResourceNode());
252
        }
253
254
        if ($user) {
255
            $node->setCreator($user);
256
        }
257
258
        $link = new ResourceLink();
259
        $link->setCourse($course);
260
        $link->setVisibility(ResourceLink::VISIBILITY_PUBLISHED);
261
        $node->addResourceLink($link);
262
263
        $em->persist($node);
264
        $em->flush();
265
266
        return $node;
267
    }
268
269
    /**
270
     * Create (if missing) a folder under $parent using the CDocument API path.
271
     * The folder is attached under the given $parent (not the course root),
272
     * and linked to the course (cid) and optionally the session (sid).
273
     */
274
    public function ensureFolder(
275
        Course $course,
276
        ResourceNode $parent,
277
        string $folderTitle,
278
        int $visibility = ResourceLink::VISIBILITY_DRAFT,
279
        ?Session $session = null
280
    ): ResourceNode {
281
        try {
282
            if ($child = $this->findChildNodeByTitle($parent, $folderTitle)) {
283
                return $child;
284
            }
285
286
            /** @var User|null $user */
287
            $user = api_get_user_entity();
288
            $creatorId = $user?->getId();
289
290
            $doc = new CDocument();
291
            $doc->setTitle($folderTitle);
292
            $doc->setFiletype('folder');
293
            $doc->setParentResourceNode($parent->getId());
294
295
            $link = [
296
                'cid' => $course->getId(),
297
                'visibility' => $visibility,
298
            ];
299
            if ($session && method_exists($session, 'getId')) {
300
                $link['sid'] = $session->getId();
301
            }
302
            $doc->setResourceLinkArray([$link]);
303
304
            if ($user) {
305
                $doc->setCreator($user);
306
            }
307
308
            $em = $this->em();
309
            $em->persist($doc);
310
            $em->flush();
311
312
            return $doc->getResourceNode();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $doc->getResourceNode() could return the type null which is incompatible with the type-hinted return Chamilo\CoreBundle\Entity\ResourceNode. Consider adding an additional type-check to rule them out.
Loading history...
313
        } catch (Throwable $e) {
314
            error_log('[CDocumentRepo.ensureFolder] ERROR '.$e->getMessage());
315
316
            throw $e;
317
        }
318
    }
319
320
    /**
321
     * Create a file under $parent using the CDocument API path.
322
     * The file is linked to the course (cid) and optionally the session (sid).
323
     */
324
    public function createFileInFolder(
325
        Course $course,
326
        ResourceNode $parent,
327
        UploadedFile $uploaded,
328
        string $comment,
329
        int $visibility,
330
        ?Session $session = null
331
    ): ResourceNode {
332
        /** @var User|null $user */
333
        $user = api_get_user_entity();
334
335
        $title = $uploaded->getClientOriginalName();
336
337
        $doc = new CDocument();
338
        $doc->setTitle($title);
339
        $doc->setFiletype('file');
340
        $doc->setComment($comment);
341
        $doc->setParentResourceNode($parent->getId());
342
343
        $link = [
344
            'cid' => $course->getId(),
345
            'visibility' => $visibility,
346
        ];
347
        if ($session && method_exists($session, 'getId')) {
348
            $link['sid'] = $session->getId();
349
        }
350
        $doc->setResourceLinkArray([$link]);
351
352
        $doc->setUploadFile($uploaded);
353
354
        if ($user) {
355
            $doc->setCreator($user);
356
        }
357
358
        $em = $this->em();
359
        $em->persist($doc);
360
        $em->flush();
361
362
        return $doc->getResourceNode();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $doc->getResourceNode() could return the type null which is incompatible with the type-hinted return Chamilo\CoreBundle\Entity\ResourceNode. Consider adding an additional type-check to rule them out.
Loading history...
363
    }
364
365
    public function findChildNodeByTitle(ResourceNode $parent, string $title): ?ResourceNode
366
    {
367
        return $this->em()
368
            ->getRepository(ResourceNode::class)
369
            ->findOneBy([
370
                'parent' => $parent->getId(),
371
                'title' => $title,
372
            ])
373
        ;
374
    }
375
376
    /**
377
     * Ensure "Learning paths" exists directly under the course resource node.
378
     * Links are created for course (and optional session) context.
379
     */
380
    public function ensureLearningPathSystemFolder(Course $course, ?Session $session = null): ResourceNode
381
    {
382
        $courseRoot = $course->getResourceNode();
383
        if (!$courseRoot instanceof ResourceNode) {
384
            throw new RuntimeException('Course has no ResourceNode root.');
385
        }
386
387
        // Try common i18n variants first
388
        $candidates = array_values(array_unique(array_filter([
389
            \function_exists('get_lang') ? get_lang('Learning paths') : null,
390
            \function_exists('get_lang') ? get_lang('Learning path') : null,
391
            'Learning paths',
392
            'Learning path',
393
        ])));
394
395
        foreach ($candidates as $title) {
396
            if ($child = $this->findChildNodeByTitle($courseRoot, $title)) {
397
                return $child;
398
            }
399
        }
400
401
        // Create "Learning paths" directly under the course root
402
        return $this->ensureFolder(
403
            $course,
404
            $courseRoot,
405
            'Learning paths',
406
            ResourceLink::VISIBILITY_DRAFT,
407
            $session
408
        );
409
    }
410
411
    /**
412
     * Recursively list all files (not folders) under a CDocument folder by its iid.
413
     * Returns items ready for exporters.
414
     */
415
    public function listFilesByParentIid(int $parentIid): array
416
    {
417
        $em = $this->getEntityManager();
418
419
        /** @var CDocument|null $parentDoc */
420
        $parentDoc = $this->findOneBy(['iid' => $parentIid]);
421
        if (!$parentDoc instanceof CDocument) {
422
            return [];
423
        }
424
425
        $parentNode = $parentDoc->getResourceNode();
426
        if (!$parentNode instanceof ResourceNode) {
427
            return [];
428
        }
429
430
        $out = [];
431
        $stack = [$parentNode->getId()];
432
433
        $projectDir = Container::$container->get('kernel')->getProjectDir();
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

433
        $projectDir = Container::$container->/** @scrutinizer ignore-call */ get('kernel')->getProjectDir();

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...
434
        $resourceBase = rtrim($projectDir, '/').'/var/upload/resource';
435
436
        /** @var ResourceNodeRepository $rnRepo */
437
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
438
439
        while ($stack) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $stack of type array<integer,integer|null> 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...
440
            $pid = array_pop($stack);
441
442
            $qb = $em->createQueryBuilder()
443
                ->select('d', 'rn')
444
                ->from(CDocument::class, 'd')
445
                ->innerJoin('d.resourceNode', 'rn')
446
                ->andWhere('rn.parent = :pid')
447
                ->setParameter('pid', $pid)
448
            ;
449
450
            /** @var CDocument[] $children */
451
            $children = $qb->getQuery()->getResult();
452
453
            foreach ($children as $doc) {
454
                $filetype = (string) $doc->getFiletype();
455
                $rn = $doc->getResourceNode();
456
457
                if ('folder' === $filetype) {
458
                    if ($rn) {
459
                        $stack[] = $rn->getId();
460
                    }
461
462
                    continue;
463
                }
464
465
                if ('file' === $filetype) {
466
                    $fullPath = (string) $doc->getFullPath(); // e.g. "document/Folder/file.ext"
467
                    $relPath = preg_replace('#^document/+#', '', $fullPath) ?? $fullPath;
468
469
                    $absPath = null;
470
                    $size = 0;
471
472
                    if ($rn) {
473
                        $file = $rn->getFirstResourceFile();
474
475
                        /** @var ResourceFile|null $file */
476
                        if ($file) {
477
                            $storedRel = (string) $rnRepo->getFilename($file);
478
                            if ('' !== $storedRel) {
479
                                $candidate = $resourceBase.$storedRel;
480
                                if (is_readable($candidate)) {
481
                                    $absPath = $candidate;
482
                                    $size = (int) $file->getSize();
483
                                    if ($size <= 0 && is_file($candidate)) {
484
                                        $st = @stat($candidate);
485
                                        $size = $st ? (int) $st['size'] : 0;
486
                                    }
487
                                }
488
                            }
489
                        }
490
                    }
491
492
                    $out[] = [
493
                        'id' => (int) $doc->getIid(),
494
                        'path' => $relPath,
495
                        'size' => (int) $size,
496
                        'title' => (string) $doc->getTitle(),
497
                        'abs_path' => $absPath,
498
                    ];
499
                }
500
            }
501
        }
502
503
        return $out;
504
    }
505
506
    public function ensureChatSystemFolder(Course $course, ?Session $session = null): ResourceNode
507
    {
508
        return $this->ensureChatSystemFolderUnderCourseRoot($course, $session);
509
    }
510
511
    public function findChildDocumentFolderByTitle(ResourceNode $parent, string $title): ?ResourceNode
512
    {
513
        $em = $this->em();
514
        $docRt = $this->getResourceType();
515
        $qb = $em->createQueryBuilder()
516
            ->select('rn')
517
            ->from(ResourceNode::class, 'rn')
518
            ->innerJoin(CDocument::class, 'd', 'WITH', 'd.resourceNode = rn')
519
            ->where('rn.parent = :parent AND rn.title = :title AND rn.resourceType = :rt AND d.filetype = :ft')
520
            ->setParameters([
521
                'parent' => $parent,
522
                'title' => $title,
523
                'rt' => $docRt,
524
                'ft' => 'folder',
525
            ])
526
            ->setMaxResults(1)
527
        ;
528
529
        /** @var ResourceNode|null $node */
530
        return $qb->getQuery()->getOneOrNullResult();
531
    }
532
533
    public function ensureChatSystemFolderUnderCourseRoot(Course $course, ?Session $session = null): ResourceNode
534
    {
535
        $em = $this->em();
536
537
        try {
538
            $courseRoot = $course->getResourceNode();
539
            if (!$courseRoot) {
540
                error_log('[CDocumentRepo.ensureChatSystemFolderUnderCourseRoot] ERROR: Course has no ResourceNode root.');
541
542
                throw new RuntimeException('Course has no ResourceNode root.');
543
            }
544
            if ($child = $this->findChildDocumentFolderByTitle($courseRoot, 'chat_conversations')) {
545
                return $child;
546
            }
547
548
            if ($docsRoot = $this->getCourseDocumentsRootNode($course)) {
549
                if ($legacy = $this->findChildDocumentFolderByTitle($docsRoot, 'chat_conversations')) {
550
                    $legacy->setParent($courseRoot);
551
                    $em->persist($legacy);
552
                    $em->flush();
553
554
                    $rnRepo = Container::$container->get(ResourceNodeRepository::class);
555
                    if (method_exists($rnRepo, 'rebuildPaths')) {
556
                        $rnRepo->rebuildPaths($courseRoot);
557
                    }
558
559
                    return $legacy;
560
                }
561
            }
562
563
            return $this->ensureFolder(
564
                $course,
565
                $courseRoot,
566
                'chat_conversations',
567
                ResourceLink::VISIBILITY_DRAFT,
568
                $session
569
            );
570
        } catch (Throwable $e) {
571
            error_log('[CDocumentRepo.ensureChatSystemFolderUnderCourseRoot] ERROR '.$e->getMessage());
572
573
            throw $e;
574
        }
575
    }
576
577
    public function findChildDocumentFileByTitle(ResourceNode $parent, string $title): ?ResourceNode
578
    {
579
        $em = $this->em();
580
        $docRt = $this->getResourceType();
581
582
        $qb = $em->createQueryBuilder()
583
            ->select('rn')
584
            ->from(ResourceNode::class, 'rn')
585
            ->innerJoin(CDocument::class, 'd', 'WITH', 'd.resourceNode = rn')
586
            ->where('rn.parent = :parent')
587
            ->andWhere('rn.title = :title')
588
            ->andWhere('rn.resourceType = :rt')
589
            ->andWhere('d.filetype = :ft')
590
            ->setParameters([
591
                'parent' => $parent,
592
                'title' => $title,
593
                'rt' => $docRt,
594
                'ft' => 'file',
595
            ])
596
            ->setMaxResults(1)
597
        ;
598
599
        /** @var ResourceNode|null $node */
600
        return $qb->getQuery()->getOneOrNullResult();
601
    }
602
603
    /**
604
     * Return absolute filesystem path for a file CDocument if resolvable; null otherwise.
605
     */
606
    public function getAbsolutePathForDocument(CDocument $doc): ?string
607
    {
608
        $rn = $doc->getResourceNode();
609
        if (!$rn) {
610
            return null;
611
        }
612
613
        /** @var ResourceNodeRepository $rnRepo */
614
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
615
        $file = $rn->getFirstResourceFile();
616
617
        /** @var ResourceFile|null $file */
618
        if (!$file) {
619
            return null;
620
        }
621
622
        $storedRel = (string) $rnRepo->getFilename($file);
623
        if ('' === $storedRel) {
624
            return null;
625
        }
626
627
        $projectDir = Container::$container->get('kernel')->getProjectDir();
628
        $resourceBase = rtrim($projectDir, '/').'/var/upload/resource';
629
630
        $candidate = $resourceBase.$storedRel;
631
632
        return is_readable($candidate) ? $candidate : null;
633
    }
634
635
    private function safeTitle(string $name): string
636
    {
637
        $name = trim($name);
638
        $name = str_replace(['/', '\\'], '-', $name);
639
640
        return preg_replace('/\s+/', ' ', $name) ?: 'Untitled';
641
    }
642
643
    private function em(): EntityManagerInterface
644
    {
645
        /** @var EntityManagerInterface $em */
646
        return $this->getEntityManager();
647
    }
648
649
    /**
650
     * Returns the document folders for a given course/session/group context,
651
     * as [ document_iid => "Full/Path/To/Folder" ].
652
     *
653
     * This implementation uses ResourceLink as the main source,
654
     * assuming ResourceLink has a parent (context-aware hierarchy).
655
     */
656
    public function getAllFoldersForContext(
657
        Course $course,
658
        ?Session $session = null,
659
        ?CGroup $group = null,
660
        bool $canSeeInvisible = false,
661
        bool $getInvisibleList = false
662
    ): array {
663
        $em = $this->getEntityManager();
664
665
        $qb = $em->createQueryBuilder()
666
            ->select('d')
667
            ->from(CDocument::class, 'd')
668
            ->innerJoin('d.resourceNode', 'rn')
669
            ->innerJoin('rn.resourceLinks', 'rl')
670
            ->where('rl.course = :course')
671
            ->andWhere('d.filetype = :folderType')
672
            ->andWhere('rl.deletedAt IS NULL')
673
            ->setParameter('course', $course)
674
            ->setParameter('folderType', 'folder')
675
        ;
676
677
        // Session filter
678
        if (null !== $session) {
679
            $qb
680
                ->andWhere('rl.session = :session')
681
                ->setParameter('session', $session);
682
        } else {
683
            // In C2 many "global course documents" have session = NULL
684
            $qb->andWhere('rl.session IS NULL');
685
        }
686
687
        // Group filter
688
        if (null !== $group) {
689
            $qb
690
                ->andWhere('rl.group = :group')
691
                ->setParameter('group', $group);
692
        } else {
693
            $qb->andWhere('rl.group IS NULL');
694
        }
695
696
        // Visibility
697
        if (!$canSeeInvisible) {
698
            if ($getInvisibleList) {
699
                // Only non-published folders (hidden/pending/etc.)
700
                $qb
701
                    ->andWhere('rl.visibility <> :published')
702
                    ->setParameter('published', ResourceLink::VISIBILITY_PUBLISHED);
703
            } else {
704
                // Only visible folders
705
                $qb
706
                    ->andWhere('rl.visibility = :published')
707
                    ->setParameter('published', ResourceLink::VISIBILITY_PUBLISHED);
708
            }
709
        }
710
        // If $canSeeInvisible = true, do not filter by visibility (see everything).
711
712
        /** @var CDocument[] $documents */
713
        $documents = $qb->getQuery()->getResult();
714
715
        if (empty($documents)) {
716
            return [];
717
        }
718
719
        // 1) Index by ResourceLink id to be able to rebuild the path using the parent link
720
        $linksById = [];
721
722
        foreach ($documents as $doc) {
723
            if (!$doc instanceof CDocument) {
724
                continue;
725
            }
726
727
            $node = $doc->getResourceNode();
728
            if (!$node instanceof ResourceNode) {
729
                continue;
730
            }
731
732
            $links = $node->getResourceLinks();
733
            if (!$links instanceof \Doctrine\Common\Collections\Collection) {
734
                continue;
735
            }
736
737
            $matchingLink = null;
738
739
            foreach ($links as $candidate) {
740
                if (!$candidate instanceof ResourceLink) {
741
                    continue;
742
                }
743
744
                // Deleted links must be ignored
745
                if (null !== $candidate->getDeletedAt()) {
746
                    continue;
747
                }
748
749
                // Match same course
750
                if ($candidate->getCourse()?->getId() !== $course->getId()) {
751
                    continue;
752
                }
753
754
                // Match same session context
755
                if (null !== $session) {
756
                    if ($candidate->getSession()?->getId() !== $session->getId()) {
757
                        continue;
758
                    }
759
                } else {
760
                    if (null !== $candidate->getSession()) {
761
                        continue;
762
                    }
763
                }
764
765
                // Match same group context
766
                if (null !== $group) {
767
                    if ($candidate->getGroup()?->getIid() !== $group->getIid()) {
768
                        continue;
769
                    }
770
                } else {
771
                    if (null !== $candidate->getGroup()) {
772
                        continue;
773
                    }
774
                }
775
776
                // Visibility filter (when not allowed to see invisible items)
777
                if (!$canSeeInvisible) {
778
                    $visibility = $candidate->getVisibility();
779
780
                    if ($getInvisibleList) {
781
                        // We only want non-published items
782
                        if (ResourceLink::VISIBILITY_PUBLISHED === $visibility) {
783
                            continue;
784
                        }
785
                    } else {
786
                        // We only want published items
787
                        if (ResourceLink::VISIBILITY_PUBLISHED !== $visibility) {
788
                            continue;
789
                        }
790
                    }
791
                }
792
793
                $matchingLink = $candidate;
794
                break;
795
            }
796
797
            if (!$matchingLink instanceof ResourceLink) {
798
                // No valid link for this context, skip
799
                continue;
800
            }
801
802
            $linksById[$matchingLink->getId()] = [
803
                'doc' => $doc,
804
                'link' => $matchingLink,
805
                'node' => $node,
806
                'parent_id' => $matchingLink->getParent()?->getId(),
807
                'title' => $node->getTitle(),
808
            ];
809
        }
810
811
        if (empty($linksById)) {
812
            return [];
813
        }
814
815
        // 2) Build full folder paths per context (using ResourceLink.parent)
816
        $pathCache = [];
817
        $folders = [];
818
819
        foreach ($linksById as $id => $data) {
820
            $path = $this->buildFolderPathForLink($id, $linksById, $pathCache);
821
822
            if ('' === $path) {
823
                continue;
824
            }
825
826
            /** @var CDocument $doc */
827
            $doc = $data['doc'];
828
829
            // Keep the key as CDocument iid (as before)
830
            $folders[$doc->getIid()] = $path;
831
        }
832
833
        if (empty($folders)) {
834
            return [];
835
        }
836
837
        // Natural sort so that paths appear in a human-friendly order
838
        natsort($folders);
839
840
        // If the caller explicitly requested the invisible list, the filtering was done above
841
        return $folders;
842
    }
843
844
    /**
845
     * Rebuild the "Parent folder/Child folder/..." path for a folder ResourceLink,
846
     * walking up the parent chain until a link without parent is found.
847
     *
848
     * Uses a small cache to avoid recalculating the same paths many times.
849
     *
850
     * @param array<int, array<string,mixed>> $linksById
851
     * @param array<int, string>              $pathCache
852
     */
853
    private function buildFolderPathForLink(
854
        int $id,
855
        array $linksById,
856
        array &$pathCache
857
    ): string {
858
        if (isset($pathCache[$id])) {
859
            return $pathCache[$id];
860
        }
861
862
        if (!isset($linksById[$id])) {
863
            return $pathCache[$id] = '';
864
        }
865
866
        $current = $linksById[$id];
867
        $segments = [$current['title']];
868
869
        $parentId = $current['parent_id'] ?? null;
870
        $guard = 0;
871
872
        while (null !== $parentId && isset($linksById[$parentId]) && $guard < 50) {
873
            $parent = $linksById[$parentId];
874
            array_unshift($segments, $parent['title']);
875
            $parentId = $parent['parent_id'] ?? null;
876
            $guard++;
877
        }
878
879
        $path = implode('/', $segments);
880
881
        return $pathCache[$id] = $path;
882
    }
883
}
884