Passed
Pull Request — master (#7139)
by
unknown
11:28
created

CDocumentRepository::getAllFoldersForContext()   F

Complexity

Conditions 29
Paths 5388

Size

Total Lines 186
Code Lines 97

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 29
eloc 97
c 1
b 0
f 0
nc 5388
nop 5
dl 0
loc 186
rs 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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

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