Passed
Push — master ( f3154a...a09ea0 )
by
unknown
25:27 queued 16:41
created

CDocumentRepository::createFileInFolder()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 39
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 20
nc 4
nop 6
dl 0
loc 39
rs 9.6
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\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
     * Ensure "Learning paths" exists directly under the course resource node.
382
     * Links are created for course (and optional session) context.
383
     */
384
    public function ensureLearningPathSystemFolder(Course $course, ?Session $session = null): ResourceNode
385
    {
386
        $courseRoot = $course->getResourceNode();
387
        if (!$courseRoot instanceof ResourceNode) {
388
            throw new RuntimeException('Course has no ResourceNode root.');
389
        }
390
391
        // Try common i18n variants first
392
        $candidates = array_values(array_unique(array_filter([
393
            \function_exists('get_lang') ? get_lang('Learning paths') : null,
394
            \function_exists('get_lang') ? get_lang('Learning path') : null,
395
            'Learning paths',
396
            'Learning path',
397
        ])));
398
399
        foreach ($candidates as $title) {
400
            if ($child = $this->findChildNodeByTitle($courseRoot, $title)) {
401
                return $child;
402
            }
403
        }
404
405
        // Create "Learning paths" directly under the course root
406
        return $this->ensureFolder(
407
            $course,
408
            $courseRoot,
409
            'Learning paths',
410
            ResourceLink::VISIBILITY_DRAFT,
411
            $session
412
        );
413
    }
414
415
    /**
416
     * Recursively list all files (not folders) under a CDocument folder by its iid.
417
     * Returns items ready for exporters.
418
     */
419
    public function listFilesByParentIid(int $parentIid): array
420
    {
421
        $em = $this->getEntityManager();
422
423
        /** @var CDocument|null $parentDoc */
424
        $parentDoc = $this->findOneBy(['iid' => $parentIid]);
425
        if (!$parentDoc instanceof CDocument) {
426
            return [];
427
        }
428
429
        $parentNode = $parentDoc->getResourceNode();
430
        if (!$parentNode instanceof ResourceNode) {
431
            return [];
432
        }
433
434
        $out = [];
435
        $stack = [$parentNode->getId()];
436
437
        $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

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