Passed
Pull Request — master (#7178)
by
unknown
15:23 queued 04:06
created

getAllDocumentDataByUserAndGroup()   B

Complexity

Conditions 7
Paths 24

Size

Total Lines 66
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 7
eloc 36
c 1
b 0
f 1
nc 24
nop 6
dl 0
loc 66
rs 8.4106

How to fix   Long Method   

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

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