Passed
Push — master ( b3392d...18dba7 )
by
unknown
17:42 queued 08:30
created

findChildDocumentFolderByTitle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 21
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

Loading history...
434
        $resourceBase = rtrim($projectDir, '/').'/var/upload/resource';
435
436
        /** @var ResourceNodeRepository $rnRepo */
437
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
438
439
        while ($stack) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $stack of type array<integer,integer|null> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
440
            $pid = array_pop($stack);
441
442
            $qb = $em->createQueryBuilder()
443
                ->select('d', 'rn')
444
                ->from(CDocument::class, 'd')
445
                ->innerJoin('d.resourceNode', 'rn')
446
                ->andWhere('rn.parent = :pid')
447
                ->setParameter('pid', $pid)
448
            ;
449
450
            /** @var CDocument[] $children */
451
            $children = $qb->getQuery()->getResult();
452
453
            foreach ($children as $doc) {
454
                $filetype = (string) $doc->getFiletype();
455
                $rn = $doc->getResourceNode();
456
457
                if ('folder' === $filetype) {
458
                    if ($rn) {
459
                        $stack[] = $rn->getId();
460
                    }
461
462
                    continue;
463
                }
464
465
                if ('file' === $filetype) {
466
                    $fullPath = (string) $doc->getFullPath(); // e.g. "document/Folder/file.ext"
467
                    $relPath = preg_replace('#^document/+#', '', $fullPath) ?? $fullPath;
468
469
                    $absPath = null;
470
                    $size = 0;
471
472
                    if ($rn) {
473
                        $file = $rn->getFirstResourceFile();
474
475
                        /** @var ResourceFile|null $file */
476
                        if ($file) {
477
                            $storedRel = (string) $rnRepo->getFilename($file);
478
                            if ('' !== $storedRel) {
479
                                $candidate = $resourceBase.$storedRel;
480
                                if (is_readable($candidate)) {
481
                                    $absPath = $candidate;
482
                                    $size = (int) $file->getSize();
483
                                    if ($size <= 0 && is_file($candidate)) {
484
                                        $st = @stat($candidate);
485
                                        $size = $st ? (int) $st['size'] : 0;
486
                                    }
487
                                }
488
                            }
489
                        }
490
                    }
491
492
                    $out[] = [
493
                        'id' => (int) $doc->getIid(),
494
                        'path' => $relPath,
495
                        'size' => (int) $size,
496
                        'title' => (string) $doc->getTitle(),
497
                        'abs_path' => $absPath,
498
                    ];
499
                }
500
            }
501
        }
502
503
        return $out;
504
    }
505
506
    public function ensureChatSystemFolder(Course $course, ?Session $session = null): ResourceNode
507
    {
508
        return $this->ensureChatSystemFolderUnderCourseRoot($course, $session);
509
    }
510
511
    public function findChildDocumentFolderByTitle(ResourceNode $parent, string $title): ?ResourceNode
512
    {
513
        $em    = $this->em();
514
        $docRt = $this->getResourceType();
515
        $qb = $em->createQueryBuilder()
516
            ->select('rn')
517
            ->from(ResourceNode::class, 'rn')
518
            ->innerJoin(CDocument::class, 'd', 'WITH', 'd.resourceNode = rn')
519
            ->where('rn.parent = :parent AND rn.title = :title AND rn.resourceType = :rt AND d.filetype = :ft')
520
            ->setParameters([
521
                'parent' => $parent,
522
                'title'  => $title,
523
                'rt'     => $docRt,
524
                'ft'     => 'folder',
525
            ])
526
            ->setMaxResults(1);
527
528
        /** @var ResourceNode|null $node */
529
        $node = $qb->getQuery()->getOneOrNullResult();
530
531
        return $node;
532
    }
533
534
    public function ensureChatSystemFolderUnderCourseRoot(Course $course, ?Session $session = null): ResourceNode
535
    {
536
        $em = $this->em();
537
        try {
538
            $courseRoot = $course->getResourceNode();
539
            if (!$courseRoot) {
540
                error_log('[CDocumentRepo.ensureChatSystemFolderUnderCourseRoot] ERROR: Course has no ResourceNode root.');
541
                throw new \RuntimeException('Course has no ResourceNode root.');
542
            }
543
            if ($child = $this->findChildDocumentFolderByTitle($courseRoot, 'chat_conversations')) {
544
                return $child;
545
            }
546
547
            if ($docsRoot = $this->getCourseDocumentsRootNode($course)) {
548
                if ($legacy = $this->findChildDocumentFolderByTitle($docsRoot, 'chat_conversations')) {
549
                    $legacy->setParent($courseRoot);
550
                    $em->persist($legacy);
551
                    $em->flush();
552
553
                    $rnRepo = Container::$container->get(ResourceNodeRepository::class);
554
                    if (method_exists($rnRepo, 'rebuildPaths')) {
555
                        $rnRepo->rebuildPaths($courseRoot);
556
                    }
557
                    return $legacy;
558
                }
559
            }
560
561
            $node = $this->ensureFolder(
562
                $course,
563
                $courseRoot,
564
                'chat_conversations',
565
                ResourceLink::VISIBILITY_DRAFT,
566
                $session
567
            );
568
569
            return $node;
570
571
        } catch (\Throwable $e) {
572
            error_log('[CDocumentRepo.ensureChatSystemFolderUnderCourseRoot] ERROR '.$e->getMessage());
573
            throw $e;
574
        }
575
    }
576
577
    public function findChildDocumentFileByTitle(ResourceNode $parent, string $title): ?ResourceNode
578
    {
579
        $em    = $this->em();
580
        $docRt = $this->getResourceType();
581
582
        $qb = $em->createQueryBuilder()
583
            ->select('rn')
584
            ->from(ResourceNode::class, 'rn')
585
            ->innerJoin(CDocument::class, 'd', 'WITH', 'd.resourceNode = rn')
586
            ->where('rn.parent = :parent')
587
            ->andWhere('rn.title = :title')
588
            ->andWhere('rn.resourceType = :rt')
589
            ->andWhere('d.filetype = :ft')
590
            ->setParameters([
591
                'parent' => $parent,
592
                'title'  => $title,
593
                'rt'     => $docRt,
594
                'ft'     => 'file',
595
            ])
596
            ->setMaxResults(1);
597
598
        /** @var ResourceNode|null $node */
599
        return $qb->getQuery()->getOneOrNullResult();
600
    }
601
602
    /**
603
     * Return absolute filesystem path for a file CDocument if resolvable; null otherwise.
604
     */
605
    public function getAbsolutePathForDocument(CDocument $doc): ?string
606
    {
607
        $rn = $doc->getResourceNode();
608
        if (!$rn) {
609
            return null;
610
        }
611
612
        /** @var ResourceNodeRepository $rnRepo */
613
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
614
        $file = $rn->getFirstResourceFile();
615
616
        /** @var ResourceFile|null $file */
617
        if (!$file) {
618
            return null;
619
        }
620
621
        $storedRel = (string) $rnRepo->getFilename($file);
622
        if ('' === $storedRel) {
623
            return null;
624
        }
625
626
        $projectDir = Container::$container->get('kernel')->getProjectDir();
627
        $resourceBase = rtrim($projectDir, '/').'/var/upload/resource';
628
629
        $candidate = $resourceBase.$storedRel;
630
631
        return is_readable($candidate) ? $candidate : null;
632
    }
633
634
    private function safeTitle(string $name): string
635
    {
636
        $name = trim($name);
637
        $name = str_replace(['/', '\\'], '-', $name);
638
639
        return preg_replace('/\s+/', ' ', $name) ?: 'Untitled';
640
    }
641
642
    private function em(): EntityManagerInterface
643
    {
644
        /** @var EntityManagerInterface $em */
645
        return $this->getEntityManager();
646
    }
647
}
648