Passed
Push — master ( 929b28...1cd861 )
by
unknown
17:25 queued 09:00
created

WikiManager::restore_wikipage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 56
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 37
nc 2
nop 10
dl 0
loc 56
rs 9.328
c 1
b 0
f 0

How to fix   Long Method    Many Parameters   

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:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Enums\ActionIcon;
6
use Chamilo\CoreBundle\Framework\Container;
7
use Chamilo\CourseBundle\Entity\CWiki;
8
use Chamilo\CourseBundle\Entity\CWikiCategory;
9
use Chamilo\CourseBundle\Entity\CWikiConf;
10
use Chamilo\CourseBundle\Entity\CWikiDiscuss;
11
use Chamilo\CourseBundle\Entity\CWikiMailcue;
12
use Chamilo\CourseBundle\Repository\CWikiRepository;
13
use Doctrine\DBAL\Connection;
14
use Doctrine\Persistence\ObjectRepository;
15
use ChamiloSession as Session;
16
17
final class WikiManager
18
{
19
    /** Legacy compat: set from index.php */
20
    private readonly CWikiRepository $wikiRepo;
21
    public string $page = 'index';
22
    public string $action = 'showpage';
23
    public ?string $charset = null;
24
    protected ?string $baseUrl = null;
25
    public ?string $url = null;
26
27
    /** Optional in-memory preload for view */
28
    private array $wikiData = [];
29
    public ?string $tbl_wiki = null;
30
    public ?string $tbl_wiki_mailcue = null;
31
32
    public function __construct(?CWikiRepository $wikiRepo = null)
33
    {
34
        if ($wikiRepo instanceof CWikiRepository) {
35
            $this->wikiRepo = $wikiRepo;
36
        } else {
37
            $em = \Database::getManager();
38
            /** @var CWikiRepository $repo */
39
            $repo = $em->getRepository(CWiki::class);
40
            $this->wikiRepo = $repo;
41
        }
42
        $this->baseUrl = $this->computeBaseUrl();
43
    }
44
45
    /** DBAL connection (plain SQL with Doctrine). */
46
    private function conn(): Connection
47
    {
48
        return Container::getEntityManager()->getConnection();
49
    }
50
51
    /** Table names (shortcuts) */
52
    private function tblWikiMailcue(): string
53
    {
54
        return 'c_wiki_mailcue';
55
    }
56
    private function tblWiki(): string
57
    {
58
        return 'c_wiki';
59
    }
60
61
    /**
62
     * Set the base URL to be used by the wiki for links.
63
     * Keeps backward compatibility by also setting $this->url.
64
     */
65
    public function setBaseUrl(string $url): void
66
    {
67
        $this->baseUrl = $url;
68
        // compat: some sites use $this->url as base string
69
        $this->url = $url;
70
    }
71
72
    /**
73
     * Get the base URL. If not previously set, compute a safe default.
74
     */
75
    public function getBaseUrl(): string
76
    {
77
        if (!empty($this->baseUrl)) {
78
            return $this->baseUrl;
79
        }
80
        $computed = api_get_self().'?'.api_get_cidreq();
81
        $this->setBaseUrl($computed);
82
83
        return $this->baseUrl;
84
    }
85
86
    /**
87
     * Helper to build URLs with additional parameters based on the current one.
88
     */
89
    public function buildUrl(array $params = []): string
90
    {
91
        $base = $this->getBaseUrl();
92
        return $params ? $base.'&'.http_build_query($params) : $base;
93
    }
94
95
    /**
96
     * Detects at runtime which column links mailcue to the page:
97
     * - 'publication_id' (some installations)
98
     * - 'id' (legacy)
99
     * - null if none exists (disables the feature)
100
     */
101
    private function mailcueLinkColumn(): ?string
102
    {
103
        static $col = null;
104
        if ($col !== null) {
105
            return $col;
106
        }
107
108
        $sm     = $this->conn()->createSchemaManager();
109
        $cols   = array_map(fn($c) => $c->getName(), $sm->listTableColumns($this->tblWikiMailcue()));
110
        $col    = in_array('publication_id', $cols, true) ? 'publication_id'
111
            : (in_array('id', $cols, true) ? 'id' : null);
112
113
        return $col;
114
    }
115
116
    /**
117
     * Returns the ID (c_wiki's `id` column) of the **first version** of the page
118
     * in the current context. This is the anchor used by the discussion/notify.
119
     */
120
    private function firstVersionIdByReflink(string $reflink): ?int
121
    {
122
        $ctx = self::ctx();
123
        $sql = 'SELECT MIN(id) AS id
124
            FROM '.$this->tblWiki().'
125
            WHERE c_id = :cid
126
              AND reflink = :ref
127
              AND COALESCE(group_id,0) = :gid
128
              AND COALESCE(session_id,0) = :sid';
129
130
        $id = $this->conn()->fetchOne($sql, [
131
            'cid' => (int)$ctx['courseId'],
132
            'ref' => html_entity_decode($reflink),
133
            'gid' => (int)$ctx['groupId'],
134
            'sid' => (int)$ctx['sessionId'],
135
        ]);
136
137
        return $id ? (int)$id : null;
138
    }
139
140
    /**
141
     * Load wiki data (iid or reflink) into $this->wikiData for view compatibility.
142
     * @param int|string|bool $wikiId iid of CWiki row or a reflink. Falsy => []
143
     */
144
    public function setWikiData($wikiId): void
145
    {
146
        $this->wikiData = self::getWikiDataFromDb($wikiId);
147
    }
148
149
    /** Query DB and return a flat array with latest-version fields in context. */
150
    private static function getWikiDataFromDb($wikiId): array
151
    {
152
        $ctx  = self::ctx();
153
        $em   = Container::getEntityManager();
154
        $repo = self::repo();
155
156
        $last = null;
157
        $pageId = 0;
158
159
        if (is_numeric($wikiId)) {
160
            /** @var CWiki|null $row */
161
            $row = $em->find(CWiki::class, (int)$wikiId);
162
            if (!$row) { return []; }
163
            $pageId = (int)($row->getPageId() ?: $row->getIid());
164
        } elseif (is_string($wikiId) && $wikiId !== '') {
165
            /** @var CWiki|null $first */
166
            $first = $repo->createQueryBuilder('w')
167
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
168
                ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($wikiId))
169
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
170
                ->orderBy('w.version', 'ASC')
171
                ->setMaxResults(1)
172
                ->getQuery()->getOneOrNullResult();
173
            if (!$first) { return []; }
174
            $pageId = (int)$first->getPageId();
175
        } else {
176
            return [];
177
        }
178
179
        if ($pageId > 0) {
180
            $qb = $repo->createQueryBuilder('w')
181
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
182
                ->andWhere('w.pageId = :pid')->setParameter('pid', $pageId)
183
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
184
                ->orderBy('w.version', 'DESC')
185
                ->setMaxResults(1);
186
187
            if ($ctx['sessionId'] > 0) {
188
                $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
189
            } else {
190
                $qb->andWhere('COALESCE(w.sessionId,0) = 0');
191
            }
192
193
            /** @var CWiki|null $last */
194
            $last = $qb->getQuery()->getOneOrNullResult();
195
        }
196
197
        if (!$last) {
198
            return [];
199
        }
200
201
        return [
202
            'iid'             => (int)$last->getIid(),
203
            'page_id'         => (int)$last->getPageId(),
204
            'title'           => (string)$last->getTitle(),
205
            'reflink'         => (string)$last->getReflink(),
206
            'content'         => (string)$last->getContent(),
207
            'user_id'         => (int)$last->getUserId(),
208
            'dtime'           => $last->getDtime(),
209
            'version'         => (int)$last->getVersion(),
210
            'visibility'      => (int)$last->getVisibility(),
211
            'visibility_disc' => (int)$last->getVisibilityDisc(),
212
            'assignment'      => (int)$last->getAssignment(),
213
            'progress'        => (string)$last->getProgress(),
214
            'score'           => (int)$last->getScore(),
215
        ];
216
    }
217
218
    /** Build request context (course/session/group + URLs). */
219
    private static function ctx(?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): array
220
    {
221
        $courseId  = $courseId  ?? api_get_course_int_id();
222
        $sessionId = $sessionId ?? api_get_session_id();
223
        $groupId   = $groupId   ?? api_get_group_id();
224
225
        return [
226
            'courseId'   => $courseId,
227
            'course'     => api_get_course_entity($courseId),
228
            'courseInfo' => api_get_course_info($courseId),
229
            'courseCode' => api_get_course_id(),
230
231
            'sessionId'  => $sessionId,
232
            'session'    => api_get_session_entity($sessionId),
233
234
            'groupId'    => $groupId,
235
236
            'baseUrl'    => api_get_path(WEB_CODE_PATH).'wiki/index.php?'.api_get_cidreq(),
237
        ];
238
    }
239
240
    /** @return CWikiRepository */
241
    private static function repo(): CWikiRepository
242
    {
243
        return Container::getEntityManager()->getRepository(CWiki::class);
244
    }
245
246
    /** @return ObjectRepository */
247
    private static function confRepo(): ObjectRepository
248
    {
249
        return Container::getEntityManager()->getRepository(CWikiConf::class);
250
    }
251
252
    /** Feature switch for categories. */
253
    private static function categoriesEnabled(): bool
254
    {
255
        return api_get_configuration_value('wiki_categories_enabled') === true
256
            || api_get_setting('wiki.wiki_categories_enabled') === 'true';
257
    }
258
259
    /** True if a reflink is available in current context (course/session/group). */
260
    public static function checktitle(
261
        string $title,
262
        ?int $courseId = null,
263
        ?int $sessionId = null,
264
        ?int $groupId = null
265
    ): bool {
266
        // Use same criterion as the whole module
267
        return self::existsByReflink($title, $courseId, $sessionId, $groupId);
268
    }
269
270
    public function editPage(): void
271
    {
272
        $ctx    = self::ctx();
273
        $em     = Container::getEntityManager();
274
        $repo   = self::repo();
275
        $userId = (int) api_get_user_id();
276
277
        // Sessions: only users allowed to edit inside the session
278
        if ($ctx['sessionId'] !== 0 && api_is_allowed_to_session_edit(false, true) === false) {
279
            api_not_allowed();
280
            return;
281
        }
282
283
        $page = self::normalizeReflink($this->page);
284
        $row  = [];
285
        $canEdit = false;
286
        $iconAssignment = '';
287
        $conf = null;
288
289
        self::dbg('enter editPage title='.$this->page.' normalized='.$page);
290
        self::dbg('ctx cid='.$ctx['courseId'].' gid='.$ctx['groupId'].' sid='.$ctx['sessionId'].' user='.$userId);
291
292
        // Historic rule: outside groups, home (index) is editable only by teacher/admin
293
        if (self::isMain($page) && (int)$ctx['groupId'] === 0
294
            && !api_is_allowed_to_edit(false, true) && !api_is_platform_admin()
295
        ) {
296
            Display::addFlash(Display::return_message('Only course managers can edit the home page (index) outside groups.', 'error'));
297
            self::dbg('block: home edit not allowed (student)');
298
            return;
299
        }
300
301
        // ---- FIRST (oldest row for this reflink in context) ----
302
        $qbFirst = $repo->createQueryBuilder('w')
303
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
304
            ->andWhere('w.reflink = :r')->setParameter('r', $page)
305
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
306
            ->orderBy('w.version', 'ASC')
307
            ->setMaxResults(1);
308
309
        if ($ctx['sessionId'] > 0) {
310
            $qbFirst->andWhere('(COALESCE(w.sessionId,0) = 0 OR w.sessionId = :sid)')
311
                ->setParameter('sid', (int)$ctx['sessionId']);
312
        } else {
313
            $qbFirst->andWhere('COALESCE(w.sessionId,0) = 0');
314
        }
315
316
        /** @var CWiki|null $first */
317
        $first = $qbFirst->getQuery()->getOneOrNullResult();
318
        self::dbg('$first '.($first ? 'HIT pid='.$first->getPageId() : 'MISS'));
319
320
        // ---- LAST (latest version in same context) ----
321
        $last = null;
322
        if ($first && $first->getPageId()) {
323
            $qbLast = $repo->createQueryBuilder('w')
324
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
325
                ->andWhere('w.pageId = :pid')->setParameter('pid', (int)$first->getPageId())
326
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
327
                ->orderBy('w.version', 'DESC')
328
                ->setMaxResults(1);
329
330
            if ($ctx['sessionId'] > 0) {
331
                $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
332
            } else {
333
                $qbLast->andWhere('COALESCE(w.sessionId,0) = 0');
334
            }
335
336
            /** @var CWiki|null $last */
337
            $last = $qbLast->getQuery()->getOneOrNullResult();
338
        }
339
        self::dbg('$last '.($last ? 'HIT iid='.$last->getIid().' ver='.$last->getVersion() : 'MISS'));
340
341
        // ---- Defaults (when page does not exist yet) ----
342
        $content = '<div class="wiki-placeholder">'.sprintf(get_lang('DefaultContent'), api_get_path(WEB_IMG_PATH)).'</div>';
343
        $title   = self::displayTitleFor($page, null);
344
        $pageId  = 0;
345
346
        // ---- Base permissions ----
347
        if (!empty($ctx['groupId'])) {
348
            $groupInfo = GroupManager::get_group_properties((int)$ctx['groupId']);
349
            $canEdit = api_is_allowed_to_edit(false, true)
350
                || api_is_platform_admin()
351
                || GroupManager::is_user_in_group($userId, $groupInfo);
352
            if (!$canEdit) {
353
                Display::addFlash(Display::return_message('Only group members can edit this page.', 'warning'));
354
                self::dbg('block: not group member');
355
                return;
356
            }
357
        } else {
358
            // Outside groups: if not home, let users reach editor; hard locks/config will gate below.
359
            $canEdit = true;
360
        }
361
362
        if ($last) {
363
            if ($last->getContent() === '' && $last->getTitle() === '' && $page === '') {
364
                Display::addFlash(Display::return_message('You must select a page.', 'error'));
365
                self::dbg('block: empty page selection');
366
                return;
367
            }
368
369
            $content = api_html_entity_decode($last->getContent());
370
            $title   = api_html_entity_decode($last->getTitle());
371
            $pageId  = (int)$last->getPageId();
372
373
            // Assignment rules
374
            if ((int)$last->getAssignment() === 1) {
375
                Display::addFlash(Display::return_message('This is an assignment page. Be careful when editing.'));
376
                $iconAssignment = Display::getMdiIcon(ActionIcon::WIKI_ASSIGNMENT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, 'Assignment');
377
            } elseif ((int)$last->getAssignment() === 2) {
378
                $iconAssignment = Display::getMdiIcon(ActionIcon::WIKI_WORK, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, 'Work');
379
                if ($userId !== (int)$last->getUserId()
380
                    && !api_is_allowed_to_edit(false, true) && !api_is_platform_admin()
381
                ) {
382
                    Display::addFlash(Display::return_message('This page is locked by the teacher for other users.', 'warning'));
383
                    self::dbg('block: assignment=2 and not owner');
384
                    return;
385
                }
386
            }
387
388
            // Hard lock by teacher
389
            if ((int)$last->getEditlock() === 1
390
                && !api_is_allowed_to_edit(false, true) && !api_is_platform_admin()
391
            ) {
392
                Display::addFlash(Display::return_message('This page is locked by the teacher.', 'warning'));
393
                self::dbg('block: teacher hard lock');
394
                return;
395
            }
396
397
            // Conf row (limits/dates)
398
            $conf = self::confRepo()->findOneBy(['cId' => $ctx['courseId'], 'pageId' => (int)$last->getPageId()]);
399
        }
400
401
        // ---- Config constraints (no redirects; just show and stop) ----
402
        if ($conf) {
403
            if ($conf->getStartdateAssig() && time() < api_strtotime($conf->getStartdateAssig())) {
404
                $msg = 'The task does not begin until: '.api_get_local_time($conf->getStartdateAssig());
405
                Display::addFlash(Display::return_message($msg, 'warning'));
406
                self::dbg('block: before start date');
407
                return;
408
            }
409
410
            if ($conf->getEnddateAssig() && time() > strtotime($conf->getEnddateAssig()) && (int)$conf->getDelayedsubmit() === 0) {
411
                $msg = 'The deadline has passed: '.api_get_local_time($conf->getEnddateAssig());
412
                Display::addFlash(Display::return_message($msg, 'warning'));
413
                self::dbg('block: after end date (no delayed submit)');
414
                return;
415
            }
416
417
            if ((int)$conf->getMaxVersion() > 0 && $last && (int)$last->getVersion() >= (int)$conf->getMaxVersion()) {
418
                Display::addFlash(Display::return_message('You have reached the maximum number of versions.', 'warning'));
419
                self::dbg('block: max versions reached');
420
                return;
421
            }
422
423
            if ((int)$conf->getMaxText() > 0 && $last && $conf->getMaxText() <= self::word_count($last->getContent())) {
424
                Display::addFlash(Display::return_message('You have reached the maximum number of words.', 'warning'));
425
                self::dbg('block: max words reached');
426
                return;
427
            }
428
429
            // Informative task block (non-blocking)
430
            if ($conf->getTask()) {
431
                $msgTask  = '<b>'.get_lang('DescriptionOfTheTask').'</b><p>'.$conf->getTask().'</p><hr>';
432
                $msgTask .= '<p>'.get_lang('StartDate').': '.($conf->getStartdateAssig() ? api_get_local_time($conf->getStartdateAssig()) : get_lang('No')).'</p>';
433
                $msgTask .= '<p>'.get_lang('EndDate').': '.($conf->getEnddateAssig() ? api_get_local_time($conf->getEnddateAssig()) : get_lang('No'));
434
                $msgTask .= ' ('.get_lang('AllowLaterSends').') '.(((int)$conf->getDelayedsubmit() === 0) ? get_lang('No') : get_lang('Yes')).'</p>';
435
                $msgTask .= '<p>'.get_lang('OtherSettings').': '.get_lang('NMaxVersion').': '.((int)$conf->getMaxVersion() ?: get_lang('No'));
436
                $msgTask .= ' '.get_lang('NMaxWords').': '.((int)$conf->getMaxText() ?: get_lang('No')).'</p>';
437
                Display::addFlash(Display::return_message($msgTask));
438
            }
439
        }
440
441
        // ---- Concurrency / editing lock (quiet admin override; show only on expiry) ----
442
        if ($last) {
443
            $lockBy = (int) $last->getIsEditing();
444
            $timeoutSec = 1200; // 20 minutes
445
            $ts = $last->getTimeEdit() ? self::toTimestamp($last->getTimeEdit()) : 0;
446
            $elapsed = time() - $ts;
447
            $expired = ($ts === 0) || ($elapsed >= $timeoutSec);
448
            $canOverride = api_is_allowed_to_edit(false, true) || api_is_platform_admin();
449
450
            self::dbg('lock check: lockBy='.$lockBy.' ts=' . ($ts ? date('c',$ts) : 'NULL') .
451
                ' elapsed='.$elapsed.' expired=' . ($expired?'1':'0') .
452
                ' canOverride=' . ($canOverride?'1':'0'));
453
454
            if ($lockBy !== 0 && $lockBy !== $userId) {
455
                if ($expired || $canOverride) {
456
                    // Take over the lock
457
                    $last->setIsEditing($userId);
458
                    $last->setTimeEdit(new \DateTime('now', new \DateTimeZone('UTC')));
459
                    $em->flush();
460
461
                    // Only notify if the previous lock actually expired; silent on teacher/admin override
462
                    if ($expired) {
463
                        Display::addFlash(
464
                            Display::return_message('The previous editing lock expired. You now have the lock.', 'normal', false)
465
                        );
466
                    }
467
468
                    self::dbg('lock takeover by user='.$userId.' (expired=' . ($expired?'1':'0') . ')');
469
                } else {
470
                    // Active lock and cannot override → inform and stop (no redirect)
471
                    $rest = max(0, $timeoutSec - $elapsed);
472
                    $info = api_get_user_info($lockBy);
473
                    if ($info) {
474
                        $msg = get_lang('ThisPageisBeginEditedBy').PHP_EOL
475
                            .UserManager::getUserProfileLink($info).PHP_EOL
476
                            .get_lang('ThisPageisBeginEditedTryLater').PHP_EOL
477
                            .date('i', $rest).PHP_EOL
478
                            .get_lang('MinMinutes');
479
                        Display::addFlash(Display::return_message($msg, 'normal', false));
480
                    } else {
481
                        Display::addFlash(Display::return_message('This page is currently being edited by another user.', 'normal', false));
482
                    }
483
                    self::dbg('stop: lock active and not override-able');
484
                    return;
485
                }
486
            }
487
488
            // If no lock, set it now (best-effort)
489
            if ($lockBy === 0) {
490
                Display::addFlash(Display::return_message(get_lang('WarningMaxEditingTime')));
491
                $last->setIsEditing($userId);
492
                $last->setTimeEdit(new \DateTime('now', new \DateTimeZone('UTC')));
493
                $em->flush();
494
                self::dbg('lock set by user='.$userId);
495
            }
496
        }
497
498
        // ------- FORM -------
499
        $url  = $ctx['baseUrl'].'&'.http_build_query(['action' => 'edit', 'title' => $page]);
500
        $form = new FormValidator('wiki', 'post', $url);
501
        $form->addElement('header', $iconAssignment.str_repeat('&nbsp;', 3).api_htmlentities($title));
502
503
        // Default values
504
        $row = [
505
            'id'              => (int)($last?->getIid() ?? 0),
506
            'page_id'         => (int)($last?->getPageId() ?? $pageId),
507
            'reflink'         => $page,
508
            'title'           => $title,
509
            'content'         => $content,
510
            'version'         => (int)($last?->getVersion() ?? 0),
511
            'progress'        => (string)($last?->getProgress() ?? ''),
512
            'comment'         => '',
513
            'assignment'      => (int)($last?->getAssignment() ?? 0),
514
        ];
515
516
        // Preselect categories
517
        if ($last && true === api_get_configuration_value('wiki_categories_enabled')) {
518
            /** @var CWiki $wikiRow */
519
            $wikiRow = $em->find(CWiki::class, (int)$last->getIid());
520
            foreach ($wikiRow->getCategories() as $category) {
521
                $row['category'][] = $category->getId();
522
            }
523
        }
524
525
        // Version guard in session
526
        Session::write('_version', (int)($row['version'] ?? 0));
527
528
        self::dbg('rendering edit form for page='.$page);
529
        self::setForm($form, $row);
530
        $form->addElement('hidden', 'title');
531
        $form->addButtonSave(get_lang('Save'), 'SaveWikiChange');
532
533
        $form->setDefaults($row);
534
        $form->display();
535
536
        // -------- SAVE ----------
537
        if ($form->validate()) {
538
            $values = $form->exportValues();
539
540
            if (empty($values['title'])) {
541
                Display::addFlash(Display::return_message(get_lang('NoWikiPageTitle'), 'error'));
542
            } elseif (!self::double_post($values['wpost_id'])) {
543
                // ignore duplicate post
544
            } elseif (!empty($values['version'])
545
                && (int)Session::read('_version') !== 0
546
                && (int)$values['version'] !== (int)Session::read('_version')
547
            ) {
548
                Display::addFlash(Display::return_message(get_lang('EditedByAnotherUser'), 'error'));
549
            } else {
550
                $returnMessage = self::saveWiki($values);
551
                Display::addFlash(Display::return_message($returnMessage, 'confirmation'));
552
553
                // Best-effort: clear lock after save
554
                if ($last) {
555
                    $last->setIsEditing(0);
556
                    $last->setTimeEdit(null);
557
                    $em->flush();
558
                }
559
            }
560
561
            $wikiData = $this->getWikiData();
562
            $redirectUrl = $ctx['baseUrl'].'&action=showpage&title='.urlencode(self::normalizeReflink($wikiData['reflink'] ?? $page));
563
            header('Location: '.$redirectUrl);
564
            exit;
565
        }
566
    }
567
568
    /** Public getter for the “view preload”. */
569
    public function getWikiData(): array
570
    {
571
        return $this->wikiData ?? [];
572
    }
573
574
    /** Very simple anti double-post using session. */
575
    public static function double_post($wpost_id): bool
576
    {
577
        $key = '_wiki_wpost_seen';
578
        $seen = (array) (Session::read($key) ?? []);
579
        if (in_array($wpost_id, $seen, true)) {
580
            return false;
581
        }
582
        $seen[] = $wpost_id;
583
        Session::write($key, $seen);
584
        return true;
585
    }
586
587
    /** Redirect helper to the main page. */
588
    private function redirectHome(): void
589
    {
590
        $ctx = self::ctx();
591
        $target = $ctx['baseUrl'].'&action=showpage&title='.urlencode($this->page ?: 'index');
592
        header('Location: '.$target);
593
        exit;
594
    }
595
596
    public static function setForm(FormValidator $form, array $row = []): void
597
    {
598
        // Toolbar by permissions
599
        $toolBar = api_is_allowed_to_edit(null, true)
600
            ? ['ToolbarSet' => 'Wiki', 'Width' => '100%', 'Height' => '400']
601
            : ['ToolbarSet' => 'WikiStudent', 'Width' => '100%', 'Height' => '400', 'UserStatus' => 'student'];
602
603
        // Content + comment
604
        $form->addHtmlEditor('content', get_lang('Content'), false, false, $toolBar);
605
        $form->addElement('text', 'comment', get_lang('Comments'));
606
607
        // Progress select (values 0..100 step 10)
608
        $progressValues = ['' => ''];
609
        for ($i = 10; $i <= 100; $i += 10) { $progressValues[(string)$i] = (string)$i; }
610
        // 5th parameter: attributes as array
611
        $form->addElement('select', 'progress', get_lang('Progress'), $progressValues, []);
612
613
        // Categories
614
        $catsEnabled = api_get_configuration_value('wiki_categories_enabled') === true
615
            || api_get_setting('wiki.wiki_categories_enabled') === 'true';
616
617
        if ($catsEnabled) {
618
            $em = Container::getEntityManager();
619
            $categories = $em->getRepository(CWikiCategory::class)->findByCourse(api_get_course_entity());
620
621
            $form->addSelectFromCollection(
622
                'category',
623
                get_lang('Categories'),
624
                $categories,
625
                ['multiple' => 'multiple'],
626
                false,
627
                'getNodeName'
628
            );
629
        }
630
631
        // Advanced params (only for teachers/admin and not on index)
632
        if ((api_is_allowed_to_edit(false, true) || api_is_platform_admin())
633
            && isset($row['reflink']) && $row['reflink'] !== 'index'
634
        ) {
635
            $form->addElement('advanced_settings', 'advanced_params', get_lang('AdvancedParameters'));
636
            $form->addElement('html', '<div id="advanced_params_options" style="display:none">');
637
638
            // Task description
639
            $form->addHtmlEditor(
640
                'task',
641
                get_lang('DescriptionOfTheTask'),
642
                false,
643
                false,
644
                ['ToolbarSet' => 'wiki_task', 'Width' => '100%', 'Height' => '200']
645
            );
646
647
            // Feedbacks + progress goals
648
            $form->addElement('label', null, get_lang('AddFeedback'));
649
650
            $form->addElement('textarea', 'feedback1', get_lang('Feedback1'));
651
            $form->addElement('select', 'fprogress1', get_lang('FProgress'), $progressValues, []);
652
653
            $form->addElement('textarea', 'feedback2', get_lang('Feedback2'));
654
            $form->addElement('select', 'fprogress2', get_lang('FProgress'), $progressValues, []);
655
656
            $form->addElement('textarea', 'feedback3', get_lang('Feedback3'));
657
            $form->addElement('select', 'fprogress3', get_lang('FProgress'), $progressValues, []);
658
659
            // Dates (toggles)
660
            $form->addElement('checkbox', 'initstartdate', null, get_lang('StartDate'), ['id' => 'start_date_toggle']);
661
            $row['initstartdate'] = empty($row['startdate_assig']) ? null : 1;
662
            $style = empty($row['startdate_assig']) ? 'display:none' : 'display:block';
663
            $form->addElement('html', '<div id="start_date" style="'.$style.'">');
664
            $form->addDatePicker('startdate_assig', '');
665
            $form->addElement('html', '</div>');
666
667
            $form->addElement('checkbox', 'initenddate', null, get_lang('EndDate'), ['id' => 'end_date_toggle']);
668
            $row['initenddate'] = empty($row['enddate_assig']) ? null : 1;
669
            $style = empty($row['enddate_assig']) ? 'display:none' : 'display:block';
670
            $form->addElement('html', '<div id="end_date" style="'.$style.'">');
671
            $form->addDatePicker('enddate_assig', '');
672
            $form->addElement('html', '</div>');
673
674
            // Limits & flags
675
            $form->addElement('checkbox', 'delayedsubmit', null, get_lang('AllowLaterSends'));
676
            $form->addElement('text', 'max_text', get_lang('NMaxWords'));
677
            $form->addElement('text', 'max_version', get_lang('NMaxVersion'));
678
            $form->addElement('checkbox', 'assignment', null, get_lang('CreateAssignmentPage'));
679
680
            $form->addElement('html', '</div>');
681
        }
682
683
        // Hidden fields
684
        $form->addElement('hidden', 'page_id');
685
        $form->addElement('hidden', 'reflink');
686
        $form->addElement('hidden', 'version');
687
        $form->addElement('hidden', 'wpost_id', api_get_unique_id());
688
    }
689
690
691
    /** Return all rows being edited (is_editing != 0) respecting session condition. */
692
    public static function getAllWiki(?int $courseId = null, ?int $sessionId = null): array
693
    {
694
        $ctx  = self::ctx($courseId, $sessionId, null);
695
        $repo = self::repo();
696
697
        $qb = $repo->createQueryBuilder('w')
698
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
699
            ->andWhere('COALESCE(w.isEditing, 0) <> 0')
700
            ->orderBy('w.timeEdit', 'DESC');
701
702
        if ($ctx['sessionId'] > 0) {
703
            $qb->andWhere('(COALESCE(w.sessionId,0) = 0 OR w.sessionId = :sid)')
704
                ->setParameter('sid', $ctx['sessionId']);
705
        } else {
706
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
707
        }
708
709
        return $qb->getQuery()->getArrayResult();
710
    }
711
712
    /** If "view" is an old version, show a notice against latest. */
713
    public function checkLastVersion($viewId): void
714
    {
715
        if (empty($viewId)) {
716
            return;
717
        }
718
        $ctx = self::ctx();
719
        $em  = Container::getEntityManager();
720
721
        /** @var CWiki|null $row */
722
        $row = $em->getRepository(CWiki::class)->find((int)$viewId);
723
        if (!$row) {
724
            return;
725
        }
726
727
        $qb = $em->getRepository(CWiki::class)->createQueryBuilder('w')
728
            ->select('w.iid')
729
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
730
            ->andWhere('w.pageId = :pid')->setParameter('pid', (int)$row->getPageId())
731
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
732
            ->orderBy('w.version', 'DESC')
733
            ->setMaxResults(1);
734
735
        if ($ctx['sessionId'] > 0) {
736
            $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
737
        } else {
738
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
739
        }
740
741
        $latest = $qb->getQuery()->getOneOrNullResult();
742
        if ($latest && (int)($latest['iid'] ?? 0) !== (int)$viewId) {
743
            Display::addFlash(
744
                Display::return_message(get_lang('You are not viewing the most recent version'), 'warning', false)
745
            );
746
        }
747
    }
748
749
    /** Top action bar (classic look). */
750
    public function showActionBar(): void
751
    {
752
        $ctx   = self::ctx();
753
        $page  = (string) $this->page;
754
        $left  = '';
755
756
        $left .= Display::url(
757
            Display::getMdiIcon(ActionIcon::HOME, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Home')),
758
            $ctx['baseUrl'].'&'.http_build_query(['action' => 'showpage', 'title' => 'index'])
759
        );
760
761
        if (api_is_allowed_to_session_edit(false, true) && api_is_allowed_to_edit()) {
762
            $left .= Display::url(
763
                Display::getMdiIcon(ActionIcon::ADD, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('AddNew')),
764
                $ctx['baseUrl'].'&action=addnew'
765
            );
766
        }
767
768
        if (self::categoriesEnabled() && (api_is_allowed_to_edit(false, true) || api_is_platform_admin())) {
769
            $left .= Display::url(
770
                Display::getMdiIcon(ActionIcon::CREATE_CATEGORY, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Categories')),
771
                $ctx['baseUrl'].'&action=category'
772
            );
773
774
            $addNewStatus = (int) self::check_addnewpagelock();
775
            if ($addNewStatus === 0) {
776
                $left .= Display::url(
777
                    Display::getMdiIcon(ActionIcon::LOCK, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('AddOptionProtected')),
778
                    $ctx['baseUrl'].'&'.http_build_query([
779
                        'action'     => 'showpage',
780
                        'title'      => api_htmlentities('index'),
781
                        'actionpage' => 'unlockaddnew',
782
                    ])
783
                );
784
            } else {
785
                $left .= Display::url(
786
                    Display::getMdiIcon(ActionIcon::UNLOCK, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('AddOptionUnprotected')),
787
                    $ctx['baseUrl'].'&'.http_build_query([
788
                        'action'     => 'showpage',
789
                        'title'      => api_htmlentities('index'),
790
                        'actionpage' => 'lockaddnew',
791
                    ])
792
                );
793
            }
794
        }
795
796
        $left .= Display::url(
797
            Display::getMdiIcon(ActionIcon::SEARCH, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Search')),
798
            $ctx['baseUrl'].'&action=searchpages'
799
        );
800
801
        $left .= Display::url(
802
            Display::getMdiIcon(ActionIcon::INFORMATION, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Statistics')),
803
            $ctx['baseUrl'].'&'.http_build_query(['action' => 'more', 'title' => api_htmlentities(urlencode($page))])
804
        );
805
806
        $left .= Display::url(
807
            Display::getMdiIcon(ActionIcon::LIST, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('All pages')),
808
            $ctx['baseUrl'].'&action=allpages'
809
        );
810
811
        $left .= Display::url(
812
            Display::getMdiIcon(ActionIcon::HISTORY, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Recent changes')),
813
            $ctx['baseUrl'].'&action=recentchanges'
814
        );
815
816
        $frm = new FormValidator('wiki_search', 'get', $ctx['baseUrl'], '', [], FormValidator::LAYOUT_INLINE);
817
        $frm->addText('search_term', get_lang('SearchTerm'), false);
818
        $frm->addHidden('cid',     $ctx['courseId']);
819
        $frm->addHidden('sid', $ctx['sessionId']);
820
        $frm->addHidden('gid',     $ctx['groupId']);
821
        $frm->addHidden('gradebook',  '0');
822
        $frm->addHidden('origin',     '');
823
        $frm->addHidden('action',     'searchpages');
824
        $frm->addButtonSearch(get_lang('Search'));
825
        $right = $frm->returnForm();
826
827
        echo self::twToolbarHtml($left, $right);
828
    }
829
830
    /** Concurrency guard: mark/unmark is_editing for current page. */
831
    public function blockConcurrentEditions(int $userId, string $action): void
832
    {
833
        try {
834
            $ctx = self::ctx();
835
            $em  = Container::getEntityManager();
836
837
            if ($action === 'edit' && !empty($this->page)) {
838
                $em->createQuery('UPDATE Chamilo\CourseBundle\Entity\CWiki w
839
                    SET w.isEditing = 1, w.timeEdit = :now
840
                    WHERE w.cId = :cid AND w.reflink = :r AND COALESCE(w.groupId,0) = :gid')
841
                    ->setParameter('now', api_get_utc_datetime(null, false, true))
842
                    ->setParameter('cid', $ctx['courseId'])
843
                    ->setParameter('r', html_entity_decode($this->page))
844
                    ->setParameter('gid', (int)$ctx['groupId'])
845
                    ->execute();
846
            } else {
847
                $em->createQuery('UPDATE Chamilo\CourseBundle\Entity\CWiki w
848
                    SET w.isEditing = 0
849
                    WHERE w.cId = :cid AND COALESCE(w.groupId,0) = :gid AND COALESCE(w.sessionId,0) = :sid')
850
                    ->setParameter('cid', $ctx['courseId'])
851
                    ->setParameter('gid', (int)$ctx['groupId'])
852
                    ->setParameter('sid', (int)$ctx['sessionId'])
853
                    ->execute();
854
            }
855
        } catch (\Throwable $e) {
856
            // silent best-effort
857
        }
858
    }
859
860
    public static function delete_wiki(?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): string
861
    {
862
863
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
864
        $em   = Container::getEntityManager();
865
        $conn = $em->getConnection();
866
867
        $cid = (int) $ctx['courseId'];
868
        $gid = (int) $ctx['groupId'];
869
        $sid = (int) $ctx['sessionId'];
870
871
        $predGroup   = $gid === 0 ? '(group_id IS NULL OR group_id = 0)'     : 'group_id = :gid';
872
        $predSession = $sid === 0 ? '(session_id IS NULL OR session_id = 0)' : 'session_id = :sid';
873
874
        $pre = (int) $conn->fetchOne(
875
            "SELECT COUNT(*) FROM c_wiki WHERE c_id = :cid AND $predGroup AND $predSession",
876
            array_filter(['cid'=>$cid,'gid'=>$gid,'sid'=>$sid], static fn($v)=>true),
877
        );
878
879
        if ($pre === 0) {
880
            return get_lang('WikiDeleted').' (0 rows in this context)';
881
        }
882
883
        $conn->beginTransaction();
884
        try {
885
            $deletedDiscuss = $conn->executeStatement(
886
                "DELETE d FROM c_wiki_discuss d
887
             WHERE d.c_id = :cid
888
               AND d.publication_id IN (
889
                    SELECT DISTINCT w.page_id
890
                    FROM c_wiki w
891
                    WHERE w.c_id = :cid
892
                      AND $predGroup
893
                      AND $predSession
894
               )",
895
                array_filter(['cid'=>$cid,'gid'=>$gid,'sid'=>$sid], static fn($v)=>true),
896
            );
897
898
            $deletedConf = $conn->executeStatement(
899
                "DELETE c FROM c_wiki_conf c
900
             WHERE c.c_id = :cid
901
               AND c.page_id IN (
902
                    SELECT DISTINCT w.page_id
903
                    FROM c_wiki w
904
                    WHERE w.c_id = :cid
905
                      AND $predGroup
906
                      AND $predSession
907
               )",
908
                array_filter(['cid'=>$cid,'gid'=>$gid,'sid'=>$sid], static fn($v)=>true),
909
            );
910
911
            $deletedRelCat = $conn->executeStatement(
912
                "DELETE rc FROM c_wiki_rel_category rc
913
             WHERE rc.wiki_id IN (
914
                SELECT w.iid
915
                FROM c_wiki w
916
                WHERE w.c_id = :cid
917
                  AND $predGroup
918
                  AND $predSession
919
             )",
920
                array_filter(['cid'=>$cid,'gid'=>$gid,'sid'=>$sid], static fn($v)=>true),
921
            );
922
923
            $deletedMailcue = $conn->executeStatement(
924
                "DELETE m FROM c_wiki_mailcue m
925
             WHERE m.c_id = :cid
926
               AND ".($gid === 0 ? '(m.group_id IS NULL OR m.group_id = 0)' : 'm.group_id = :gid')."
927
               AND ".($sid === 0 ? '(m.session_id IS NULL OR m.session_id = 0)' : 'm.session_id = :sid'),
928
                array_filter(['cid'=>$cid,'gid'=>$gid,'sid'=>$sid], static fn($v)=>true),
929
            );
930
931
            $deletedWiki = $conn->executeStatement(
932
                "DELETE w FROM c_wiki w
933
             WHERE w.c_id = :cid
934
               AND $predGroup
935
               AND $predSession",
936
                array_filter(['cid'=>$cid,'gid'=>$gid,'sid'=>$sid], static fn($v)=>true),
937
            );
938
939
            $conn->commit();
940
941
            return get_lang('WikiDeleted')." (versions=$deletedWiki, comments=$deletedDiscuss, conf=$deletedConf, catRel=$deletedRelCat, watchers=$deletedMailcue)";
942
        } catch (\Throwable $e) {
943
            $conn->rollBack();
944
            // Short and clear message
945
            return get_lang('Delete failed');
946
        }
947
    }
948
949
    /** Returns true if there is at least one version of a page (reflink) in the given context */
950
    private static function existsByReflink(string $reflink, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): bool
951
    {
952
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
953
        $qb   = self::repo()->createQueryBuilder('w')
954
            ->select('COUNT(w.iid)')
955
            ->andWhere('w.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
956
            ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
957
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
958
959
        if ((int)$ctx['sessionId'] > 0) {
960
            $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
961
        } else {
962
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
963
        }
964
965
        return ((int)$qb->getQuery()->getSingleScalarResult()) > 0;
966
    }
967
968
    /**
969
     * Core save (new page or new version). Single source of truth.
970
     */
971
    public static function saveWiki(array $values, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): string
972
    {
973
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
974
        $em   = Container::getEntityManager();
975
        $repo = self::repo();
976
        $conn = $em->getConnection();
977
978
        $userId = api_get_user_id();
979
        $now    = new \DateTime('now', new \DateTimeZone('UTC'));
980
981
        // --- sanitize + normalize ---
982
        $rawTitle = trim((string)($values['title'] ?? ''));
983
        if ($rawTitle === '') {
984
            return get_lang('NoWikiPageTitle');
985
        }
986
987
        // Prepare safe strings (emoji-safe)
988
        $values['title']   = self::utf8mb4_safe_entities((string) ($values['title']   ?? ''));
989
        $values['content'] = self::utf8mb4_safe_entities((string) ($values['content'] ?? ''));
990
        $values['comment'] = self::utf8mb4_safe_entities((string) ($values['comment'] ?? ''));
991
992
        $content = $values['content'] ?? '';
993
        if ($content === '') {
994
            $content = '<p>&nbsp;</p>'; // minimal content
995
        }
996
        if (api_get_setting('htmlpurifier_wiki') === 'true') {
997
            $content = Security::remove_XSS($content);
998
        }
999
1000
        // Extract link tokens ([[...]])
1001
        $linkTo = self::links_to($content);
1002
1003
        // Create vs update
1004
        $incomingPageId = (int)($values['page_id'] ?? 0);
1005
        $isNewPage      = ($incomingPageId === 0);
1006
1007
        // ---------- Determine reflink (KEY FIX) ----------
1008
        // Prefer an explicit 'reflink' if provided; else derive from the typed title.
1009
        $explicitRef = trim((string)($values['reflink'] ?? ''));
1010
        $candidate   = $explicitRef !== '' ? $explicitRef : $rawTitle;
1011
1012
        if ($isNewPage) {
1013
            // For NEW pages, build the reflink from what the user typed, NOT from any outer GET param.
1014
            // Normalize but only collapse to 'index' if the user explicitly typed an alias of Home.
1015
            $reflink = self::normalizeToken($candidate);
1016
1017
            $homeAliases = array_filter([
1018
                'index',
1019
                self::normalizeToken((string) (get_lang('Home') ?: 'Home')),
1020
            ]);
1021
1022
            if (in_array($reflink, $homeAliases, true)) {
1023
                $reflink = 'index';
1024
            }
1025
        } else {
1026
            // For existing pages, keep behavior consistent with previous code
1027
            $reflink = self::normalizeReflink($candidate);
1028
        }
1029
1030
        if (method_exists(__CLASS__, 'dbg')) {
1031
            self::dbg('[SAVE] isNewPage=' . ($isNewPage ? '1' : '0')
1032
                . ' | rawTitle=' . $rawTitle
1033
                . ' | explicitRef=' . ($explicitRef === '' ? '(empty)' : $explicitRef)
1034
                . ' | computedReflink=' . $reflink
1035
                . ' | cid='.(int)$ctx['courseId'].' gid='.(int)$ctx['groupId'].' sid='.(int)$ctx['sessionId']);
1036
        }
1037
1038
        // --- If NEW page: abort if reflink already exists in this context ---
1039
        if ($isNewPage) {
1040
            $qbExists = $repo->createQueryBuilder('w')
1041
                ->select('w.iid')
1042
                ->andWhere('w.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
1043
                ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
1044
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
1045
1046
            if ((int)$ctx['sessionId'] > 0) {
1047
                $qbExists->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
1048
            } else {
1049
                $qbExists->andWhere('COALESCE(w.sessionId,0) = 0');
1050
            }
1051
1052
            $qbExists->orderBy('w.version', 'DESC')->setMaxResults(1);
1053
1054
            if (method_exists(__CLASS__, 'dbg')) {
1055
                $dql = $qbExists->getDQL();
1056
                $sql = $qbExists->getQuery()->getSQL();
1057
                $params = $qbExists->getQuery()->getParameters();
1058
                $types  = [];
1059
                foreach ($params as $p) { $types[$p->getName()] = $p->getType(); }
1060
                self::dbg('[EXISTS DQL] '.$dql);
1061
                self::dbg('[EXISTS SQL] '.$sql);
1062
                self::dbg('[EXISTS PARAMS] '.json_encode(array_reduce(iterator_to_array($params), function($a,$p){$a[$p->getName()]=$p->getValue();return $a;}, [])));
1063
                self::dbg('[EXISTS TYPES] '.json_encode($types));
1064
            }
1065
1066
            $exists = (bool) $qbExists->getQuery()->getOneOrNullResult();
1067
            if ($exists) {
1068
                return get_lang('ThePageAlreadyExists');
1069
            }
1070
        }
1071
1072
        // --- Find latest version if NOT new (by page_id) ---
1073
        $last = null;
1074
        if (!$isNewPage) {
1075
            $qb = $repo->createQueryBuilder('w')
1076
                ->andWhere('w.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
1077
                ->andWhere('w.pageId = :pid')->setParameter('pid', $incomingPageId)
1078
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1079
                ->orderBy('w.version', 'DESC')
1080
                ->setMaxResults(1);
1081
1082
            if ((int)$ctx['sessionId'] > 0) {
1083
                $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
1084
            } else {
1085
                $qb->andWhere('COALESCE(w.sessionId,0) = 0');
1086
            }
1087
1088
            /** @var CWiki|null $last */
1089
            $last = $qb->getQuery()->getOneOrNullResult();
1090
        }
1091
1092
        // base version and pageId
1093
        $version = $last ? ((int) $last->getVersion() + 1) : 1;
1094
        $pageId  = (int) $last?->getPageId();
1095
1096
        $w = new CWiki();
1097
        $w->setCId((int) $ctx['courseId']);
1098
        $w->setPageId($pageId);
1099
        $w->setReflink($reflink);
1100
        $w->setTitle($values['title']);
1101
        $w->setContent($content);
1102
        $w->setUserId($userId);
1103
1104
        // group/session as ints (0 = none)
1105
        $w->setGroupId((int) $ctx['groupId']);
1106
        $w->setSessionId((int) $ctx['sessionId']);
1107
1108
        $w->setDtime($now);
1109
1110
        // inherit flags or defaults
1111
        $w->setAddlock(       $last ? $last->getAddlock()       : 1);
1112
        $w->setEditlock(      $last ? (int) $last->getEditlock()      : 0);
1113
        $w->setVisibility(    $last ? $last->getVisibility() : 1);
1114
        $w->setAddlockDisc(   $last ? $last->getAddlockDisc() : 1);
1115
        $w->setVisibilityDisc($last ? $last->getVisibilityDisc() : 1);
1116
        $w->setRatinglockDisc($last ? $last->getRatinglockDisc() : 1);
1117
1118
        $w->setAssignment((int) ($values['assignment'] ?? ($last ? (int) $last->getAssignment() : 0)));
1119
        $w->setComment((string) ($values['comment']  ?? ''));
1120
        $w->setProgress((string) ($values['progress'] ?? ''));
1121
        $w->setScore($last ? ((int) $last->getScore() ?: 0) : 0);
1122
1123
        $w->setVersion($version);
1124
        $w->setIsEditing(0);
1125
        $w->setTimeEdit(null);
1126
        $w->setHits($last ? ((int) $last->getHits() ?: 0) : 0);
1127
1128
        $w->setLinksto($linkTo);
1129
        $w->setTag('');
1130
        $w->setUserIp(api_get_real_ip());
1131
1132
        $w->setParent($ctx['course']);
1133
        $w->setCreator(api_get_user_entity());
1134
        $groupEntity = $ctx['groupId'] ? api_get_group_entity((int)$ctx['groupId']) : null;
1135
        $w->addCourseLink($ctx['course'], $ctx['session'], $groupEntity);
1136
1137
        // Categories
1138
        if (true === api_get_configuration_value('wiki_categories_enabled')) {
1139
            $catIds = (array)($values['category'] ?? []);
1140
            if (!empty($catIds)) {
1141
                $catRepo = $em->getRepository(CWikiCategory::class);
1142
                foreach ($catIds as $catId) {
1143
                    $cat = $catRepo->find((int) $catId);
1144
                    if ($cat) { $w->addCategory($cat); }
1145
                }
1146
            }
1147
        }
1148
1149
        $em->persist($w);
1150
        $em->flush();
1151
1152
        if (method_exists(__CLASS__, 'dbg')) {
1153
            self::dbg('[SAVE] after first flush iid='.(int)$w->getIid().' pageId='.(int)$w->getPageId().' reflink='.$reflink);
1154
        }
1155
1156
        // If FIRST version of a new page, set page_id = iid
1157
        if ($isNewPage) {
1158
            $w->setPageId((int) $w->getIid());
1159
            $em->flush();
1160
            if (method_exists(__CLASS__, 'dbg')) {
1161
                self::dbg('[SAVE] after setPageId flush iid='.(int)$w->getIid().' pageId='.(int)$w->getPageId());
1162
            }
1163
            $pageId = (int) $w->getPageId();
1164
        } else {
1165
            $pageId = (int)$incomingPageId;
1166
        }
1167
1168
        // DB sanity check
1169
        $check = (int) $conn->fetchOne(
1170
            'SELECT COUNT(*) FROM c_wiki
1171
         WHERE c_id = :cid
1172
           AND reflink = :r
1173
           AND COALESCE(group_id,0) = :gid
1174
           AND '.((int)$ctx['sessionId'] > 0 ? '(COALESCE(session_id,0) IN (0,:sid))' : 'COALESCE(session_id,0) = 0'),
1175
            [
1176
                'cid' => (int)$ctx['courseId'],
1177
                'r'   => $reflink,
1178
                'gid' => (int)$ctx['groupId'],
1179
                'sid' => (int)$ctx['sessionId'],
1180
            ]
1181
        );
1182
1183
        if (method_exists(__CLASS__, 'dbg')) {
1184
            self::dbg('[SAVE] db count after save='.$check.' (reflink='.$reflink.')');
1185
        }
1186
1187
        if ($check === 0) {
1188
            throw new \RuntimeException('Wiki save failed: no row inserted (cid='.$ctx['courseId'].', reflink='.$reflink.', gid='.$ctx['groupId'].', sid='.$ctx['sessionId'].')');
1189
        }
1190
1191
        // ---- CWikiConf ----
1192
        $hasConfFields = isset($values['task']) || isset($values['feedback1']) || isset($values['feedback2'])
1193
            || isset($values['feedback3']) || isset($values['fprogress1']) || isset($values['fprogress2'])
1194
            || isset($values['fprogress3']) || isset($values['max_text']) || isset($values['max_version'])
1195
            || array_key_exists('startdate_assig', $values) || array_key_exists('enddate_assig', $values)
1196
            || isset($values['delayedsubmit']);
1197
1198
        if ($version === 1 && $hasConfFields) {
1199
            $conf = new CWikiConf();
1200
            $conf->setCId((int) $ctx['courseId']);
1201
            $conf->setPageId($pageId);
1202
            $conf->setTask((string) ($values['task'] ?? ''));
1203
            $conf->setFeedback1((string) ($values['feedback1'] ?? ''));
1204
            $conf->setFeedback2((string) ($values['feedback2'] ?? ''));
1205
            $conf->setFeedback3((string) ($values['feedback3'] ?? ''));
1206
            $conf->setFprogress1((string) ($values['fprogress1'] ?? ''));
1207
            $conf->setFprogress2((string) ($values['fprogress2'] ?? ''));
1208
            $conf->setFprogress3((string) ($values['fprogress3'] ?? ''));
1209
            $conf->setMaxText((int) ($values['max_text'] ?? 0));
1210
            $conf->setMaxVersion((int) ($values['max_version'] ?? 0));
1211
            $conf->setStartdateAssig(self::toDateTime($values['startdate_assig'] ?? null));
1212
            $conf->setEnddateAssig(self::toDateTime($values['enddate_assig'] ?? null));
1213
            $conf->setDelayedsubmit((int) ($values['delayedsubmit'] ?? 0));
1214
            $em->persist($conf);
1215
            $em->flush();
1216
        } elseif ($hasConfFields) {
1217
            /** @var CWikiConf|null $conf */
1218
            $conf = self::confRepo()->findOneBy(['cId' => (int) $ctx['courseId'], 'pageId' => $pageId]);
1219
            if ($conf) {
1220
                $conf->setTask((string) ($values['task'] ?? $conf->getTask()));
1221
                $conf->setFeedback1((string) ($values['feedback1'] ?? $conf->getFeedback1()));
1222
                $conf->setFeedback2((string) ($values['feedback2'] ?? $conf->getFeedback2()));
1223
                $conf->setFeedback3((string) ($values['feedback3'] ?? $conf->getFeedback3()));
1224
                $conf->setFprogress1((string) ($values['fprogress1'] ?? $conf->getFprogress1()));
1225
                $conf->setFprogress2((string) ($values['fprogress2'] ?? $conf->getFprogress2()));
1226
                $conf->setFprogress3((string) ($values['fprogress3'] ?? $conf->getFprogress3()));
1227
                if (isset($values['max_text']))    { $conf->setMaxText((int) $values['max_text']); }
1228
                if (isset($values['max_version'])) { $conf->setMaxVersion((int) $values['max_version']); }
1229
                if (array_key_exists('startdate_assig', $values)) { $conf->setStartdateAssig(self::toDateTime($values['startdate_assig'])); }
1230
                if (array_key_exists('enddate_assig',   $values)) { $conf->setEnddateAssig(self::toDateTime($values['enddate_assig'])); }
1231
                if (isset($values['delayedsubmit'])) { $conf->setDelayedsubmit((int) $values['delayedsubmit']); }
1232
                $em->flush();
1233
            }
1234
        }
1235
1236
        // Notify watchers (legacy: 'P' = page change)
1237
        self::check_emailcue($reflink, 'P', $now, $userId);
1238
1239
        return $isNewPage ? get_lang('TheNewPageHasBeenCreated') : get_lang('Saved');
1240
    }
1241
1242
1243
    /**
1244
     * Compat wrappers (to avoid breaking old calls).
1245
     */
1246
    public static function save_wiki(array $values, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): string
1247
    {
1248
        return self::saveWiki($values, $courseId, $sessionId, $groupId);
1249
    }
1250
1251
    public static function save_new_wiki(array $values, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): string|false
1252
    {
1253
        $msg = self::saveWiki($values, $courseId, $sessionId, $groupId);
1254
1255
        return $msg === get_lang('NoWikiPageTitle') ? false : $msg;
1256
    }
1257
1258
    /**
1259
     * Send email notifications to watchers.
1260
     * @param int|string $id_or_ref  'P' => reflink | 'D' => iid of CWiki row | 'A'/'E' => 0
1261
     */
1262
    public static function check_emailcue($id_or_ref, string $type, $lastime = '', $lastuser = ''): void
1263
    {
1264
        $ctx = self::ctx(api_get_course_int_id(), api_get_session_id(), api_get_group_id());
1265
        $em  = Container::getEntityManager();
1266
1267
        $allowSend        = false;
1268
        $emailAssignment  = null;
1269
        $emailPageName    = '';
1270
        $emailDateChanges = '';
1271
        $emailText        = '';
1272
        $watchKey         = null;
1273
        $pageReflink      = null;
1274
1275
        // When timestamp provided
1276
        if ($lastime instanceof \DateTimeInterface) {
1277
            $emailDateChanges = $lastime->format('Y-m-d H:i:s');
1278
        } elseif (is_string($lastime) && $lastime !== '') {
1279
            $emailDateChanges = $lastime;
1280
        }
1281
1282
        // Author line
1283
        $emailUserAuthor = '';
1284
        if ($lastuser) {
1285
            $ui = api_get_user_info((int) $lastuser);
1286
            $emailUserAuthor = ($type === 'P' || $type === 'D')
1287
                ? get_lang('EditedBy').': '.($ui['complete_name'] ?? '')
1288
                : get_lang('AddedBy').': '.($ui['complete_name'] ?? '');
1289
        } else {
1290
            $ui = api_get_user_info(api_get_user_id());
1291
            $emailUserAuthor = ($type === 'E')
1292
                ? get_lang('DeletedBy').': '.($ui['complete_name'] ?? '')
1293
                : get_lang('EditedBy').': '.($ui['complete_name'] ?? '');
1294
        }
1295
1296
        $repoWiki = $em->getRepository(CWiki::class);
1297
        $repoCue  = $em->getRepository(CWikiMailcue::class);
1298
1299
        // --- Resolve page + message according to event type ---
1300
        if ($type === 'P') {
1301
            // Page modified -> $id_or_ref is a reflink
1302
            /** @var CWiki|null $first */
1303
            $first = $repoWiki->createQueryBuilder('w')
1304
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1305
                ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode((string)$id_or_ref))
1306
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1307
                ->orderBy('w.version', 'ASC')
1308
                ->setMaxResults(1)
1309
                ->getQuery()->getOneOrNullResult();
1310
1311
            if ($first) {
1312
                $emailPageName = (string) $first->getTitle();
1313
                $pageReflink   = (string) $first->getReflink();
1314
                if ((int) $first->getVisibility() === 1) {
1315
                    $allowSend = true;
1316
                    $emailText = get_lang('EmailWikipageModified').' <strong>'.$emailPageName.'</strong> '.get_lang('Wiki');
1317
                    $watchKey  = 'watch:'.$pageReflink;
1318
                }
1319
            }
1320
        } elseif ($type === 'D') {
1321
            // New discussion comment -> $id_or_ref is publication_id (page_id)
1322
            /** @var CWiki|null $row */
1323
            $row = $repoWiki->createQueryBuilder('w')
1324
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1325
                ->andWhere('w.pageId = :pid')->setParameter('pid', (int)$id_or_ref)
1326
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1327
                ->orderBy('w.version', 'DESC')
1328
                ->setMaxResults(1)
1329
                ->getQuery()->getOneOrNullResult();
1330
1331
            if ($row) {
1332
                $emailPageName = (string) $row->getTitle();
1333
                $pageReflink   = (string) $row->getReflink();
1334
                if ((int) $row->getVisibilityDisc() === 1) {
1335
                    $allowSend = true;
1336
                    $emailText = get_lang('EmailWikiPageDiscAdded').' <strong>'.$emailPageName.'</strong> '.get_lang('Wiki');
1337
                    $watchKey  = 'watchdisc:'.$pageReflink;
1338
                }
1339
            }
1340
        } elseif ($type === 'A') {
1341
            // New page added (find latest row in this context)
1342
            /** @var CWiki|null $row */
1343
            $row = $repoWiki->createQueryBuilder('w')
1344
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1345
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1346
                ->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId'])
1347
                ->orderBy('w.iid', 'DESC')
1348
                ->setMaxResults(1)
1349
                ->getQuery()->getOneOrNullResult();
1350
1351
            if ($row) {
1352
                $emailPageName    = (string) $row->getTitle();
1353
                $pageReflink      = (string) $row->getReflink();
1354
                $emailDateChanges = $row->getDtime() ? $row->getDtime()->format('Y-m-d H:i:s') : $emailDateChanges;
1355
1356
                if ((int) $row->getAssignment() === 0) {
1357
                    $allowSend = true;
1358
                } elseif ((int) $row->getAssignment() === 1) {
1359
                    $emailAssignment = get_lang('AssignmentDescExtra').' ('.get_lang('AssignmentMode').')';
1360
                    $allowSend = true;
1361
                } elseif ((int) $row->getAssignment() === 2) {
1362
                    $allowSend = false; // teacher-locked work page
1363
                }
1364
1365
                $emailText = get_lang('EmailWikiPageAdded').' <strong>'.$emailPageName.'</strong> '.get_lang('In').' '.get_lang('Wiki');
1366
                // If someone subscribed after creation, use the same key as page watchers
1367
                $watchKey  = 'watch:'.$pageReflink;
1368
            }
1369
        } elseif ($type === 'E') {
1370
            // Page deleted (generic)
1371
            $allowSend = true;
1372
            $emailText = get_lang('EmailWikipageDedeleted');
1373
            if ($emailDateChanges === '') {
1374
                $emailDateChanges = date('Y-m-d H:i:s');
1375
            }
1376
        }
1377
1378
        if (!$allowSend) {
1379
            return;
1380
        }
1381
1382
        $courseInfo  = $ctx['courseInfo'] ?: (api_get_course_info_by_id((int)$ctx['courseId']) ?: []);
1383
        $courseTitle = $courseInfo['title'] ?? ($courseInfo['name'] ?? '');
1384
        $courseName  = $courseInfo['name']  ?? $courseTitle;
1385
1386
        // Group/session labels
1387
        $grpName = '';
1388
        if ((int)$ctx['groupId'] > 0) {
1389
            $g = GroupManager::get_group_properties((int)$ctx['groupId']);
1390
            $grpName = $g['name'] ?? '';
1391
        }
1392
        $sessionName = ((int)$ctx['sessionId'] > 0) ? api_get_session_name((int)$ctx['sessionId']) : '';
1393
1394
        // --- Fetch watchers filtered by type (when available) ---
1395
        $qb = $repoCue->createQueryBuilder('m')
1396
            ->andWhere('m.cId = :cid')->setParameter('cid', $ctx['courseId'])
1397
            ->andWhere('COALESCE(m.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1398
            ->andWhere('COALESCE(m.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
1399
1400
        // Only mail the relevant subscribers
1401
        if (!empty($watchKey)) {
1402
            $qb->andWhere('m.type = :t')->setParameter('t', $watchKey);
1403
        }
1404
1405
        $watchers = $qb->getQuery()->getArrayResult();
1406
        if (empty($watchers)) {
1407
            return;
1408
        }
1409
1410
        // Optional logo
1411
        $extraParams = [];
1412
        if (api_get_configuration_value('mail_header_from_custom_course_logo') === true) {
1413
            $extraParams = ['logo' => CourseManager::getCourseEmailPicture($courseInfo)];
1414
        }
1415
1416
        foreach ($watchers as $w) {
1417
            $uid = (int) ($w['userId'] ?? 0);
1418
            if ($uid === 0) {
1419
                continue;
1420
            }
1421
            // Do not email the actor themself
1422
            if ($lastuser && (int)$lastuser === $uid) {
1423
                continue;
1424
            }
1425
1426
            $uInfo = api_get_user_info($uid);
1427
            if (!$uInfo || empty($uInfo['email'])) {
1428
                continue;
1429
            }
1430
1431
            $nameTo  = $uInfo['complete_name'];
1432
            $emailTo = $uInfo['email'];
1433
            $from    = (string) api_get_setting('emailAdministrator');
1434
1435
            $subject = get_lang('Email wiki changes').' - '.$courseTitle;
1436
1437
            $body  = get_lang('DearUser').' '.api_get_person_name($uInfo['firstname'] ?? '', $uInfo['lastname'] ?? '').',<br /><br />';
1438
            if ((int)$ctx['sessionId'] === 0) {
1439
                $body .= $emailText.' <strong>'.$courseName.($grpName ? ' - '.$grpName : '').'</strong><br /><br /><br />';
1440
            } else {
1441
                $body .= $emailText.' <strong>'.$courseName.' ('.$sessionName.')'.($grpName ? ' - '.$grpName : '').'</strong><br /><br /><br />';
1442
            }
1443
            if ($emailUserAuthor) {
1444
                $body .= $emailUserAuthor.($emailDateChanges ? ' ('.$emailDateChanges.')' : '').'<br /><br /><br />';
1445
            }
1446
            if ($emailAssignment) {
1447
                $body .= $emailAssignment.'<br /><br /><br />';
1448
            }
1449
            $body .= '<span style="font-size:70%;">'.get_lang('EmailWikiChangesExt_1').': <strong>'.get_lang('NotifyChanges').'</strong><br />';
1450
            $body .= get_lang('EmailWikiChangesExt_2').': <strong>'.get_lang('NotNotifyChanges').'</strong></span><br />';
1451
1452
            @api_mail_html(
1453
                $nameTo,
1454
                $emailTo,
1455
                $subject,
1456
                $body,
1457
                $from,
1458
                $from,
1459
                [],
1460
                [],
1461
                false,
1462
                $extraParams,
1463
                ''
1464
            );
1465
        }
1466
    }
1467
1468
    /** Full view (classic structure + modern toolbar wrapper) */
1469
    public static function display_wiki_entry(
1470
        string $newtitle,
1471
        ?string $page = null,
1472
        ?int $courseId = null,
1473
        ?int $sessionId = null,
1474
        ?int $groupId = null
1475
    ): ?string {
1476
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
1477
        $em   = Container::getEntityManager();
1478
        $repo = self::repo();
1479
1480
        // Resolve the page key we will work with
1481
        $pageKey = self::normalizeReflink($newtitle !== '' ? $newtitle : ($page ?? null));
1482
1483
        // --- ONE toggle block (lock/visible/notify) with PRG redirect ---
1484
        $actionPage = $_GET['actionpage'] ?? null;
1485
        if ($actionPage !== null) {
1486
            $allowed = ['lock','unlock','visible','invisible','locknotify','unlocknotify'];
1487
1488
            if (in_array($actionPage, $allowed, true)) {
1489
                $conn = $em->getConnection();
1490
                $cid  = (int)$ctx['courseId'];
1491
                $gid  = (int)$ctx['groupId'];
1492
                $sid  = (int)$ctx['sessionId'];
1493
                $uid  = (int)api_get_user_id();
1494
1495
                $predG = 'COALESCE(group_id,0) = :gid';
1496
                $predS = 'COALESCE(session_id,0) = :sid';
1497
1498
                switch ($actionPage) {
1499
                    case 'lock':
1500
                    case 'unlock':
1501
                        // Only teachers/admins can toggle lock
1502
                        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
1503
                            Display::addFlash(Display::return_message('Not allowed to lock/unlock this page.', 'error', false));
1504
                            break;
1505
                        }
1506
                        $newVal = ($actionPage === 'lock') ? 1 : 0;
1507
                        $conn->executeStatement(
1508
                            "UPDATE c_wiki SET editlock = :v
1509
                         WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
1510
                            ['v'=>$newVal, 'cid'=>$cid, 'r'=>$pageKey, 'gid'=>$gid, 'sid'=>$sid]
1511
                        );
1512
                        break;
1513
1514
                    case 'visible':
1515
                    case 'invisible':
1516
                        // Only teachers/admins can toggle visibility
1517
                        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
1518
                            Display::addFlash(Display::return_message('Not allowed to change visibility.', 'error', false));
1519
                            break;
1520
                        }
1521
                        $newVal = ($actionPage === 'visible') ? 1 : 0;
1522
                        $conn->executeStatement(
1523
                            "UPDATE c_wiki SET visibility = :v
1524
                         WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
1525
                            ['v'=>$newVal, 'cid'=>$cid, 'r'=>$pageKey, 'gid'=>$gid, 'sid'=>$sid]
1526
                        );
1527
                        break;
1528
1529
                    case 'locknotify':
1530
                    case 'unlocknotify':
1531
                        // Session editors can subscribe/unsubscribe
1532
                        if (!api_is_allowed_to_session_edit()) {
1533
                            Display::addFlash(Display::return_message('Not allowed to (un)subscribe notifications.', 'error', false));
1534
                            break;
1535
                        }
1536
                        $watchKey = 'watch:'.$pageKey;
1537
1538
                        if ($actionPage === 'locknotify') {
1539
                            // Insert if not exists
1540
                            $conn->executeStatement(
1541
                                "INSERT INTO c_wiki_mailcue (c_id, group_id, session_id, user_id, type)
1542
                             SELECT :cid, :gid, :sid, :uid, :t
1543
                               FROM DUAL
1544
                              WHERE NOT EXISTS (
1545
                                  SELECT 1 FROM c_wiki_mailcue
1546
                                   WHERE c_id = :cid AND $predG AND $predS
1547
                                     AND user_id = :uid AND type = :t
1548
                              )",
1549
                                ['cid'=>$cid, 'gid'=>$gid, 'sid'=>$sid, 'uid'=>$uid, 't'=>$watchKey]
1550
                            );
1551
                        } else { // unlocknotify
1552
                            $conn->executeStatement(
1553
                                "DELETE FROM c_wiki_mailcue
1554
                              WHERE c_id = :cid AND $predG AND $predS
1555
                                AND user_id = :uid AND type = :t",
1556
                                ['cid'=>$cid, 'gid'=>$gid, 'sid'=>$sid, 'uid'=>$uid, 't'=>$watchKey]
1557
                            );
1558
                        }
1559
                        break;
1560
                }
1561
1562
                // PRG redirect so icons reflect the change immediately
1563
                header('Location: '.$ctx['baseUrl'].'&action=showpage&title='.urlencode($pageKey));
1564
                exit;
1565
            }
1566
        }
1567
1568
        /** @var CWiki|null $first */
1569
        $first = $repo->createQueryBuilder('w')
1570
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1571
            ->andWhere('w.reflink = :reflink')->setParameter('reflink', $pageKey)
1572
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1573
            ->orderBy('w.version', 'ASC')
1574
            ->setMaxResults(1)
1575
            ->getQuery()->getOneOrNullResult();
1576
1577
        $keyVisibility = $first?->getVisibility();
1578
        $pageId = $first?->getPageId() ?? 0;
1579
1580
        $last = null;
1581
        if ($pageId) {
1582
            $qb = $repo->createQueryBuilder('w')
1583
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1584
                ->andWhere('w.pageId = :pid')->setParameter('pid', $pageId)
1585
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1586
                ->orderBy('w.version', 'DESC')
1587
                ->setMaxResults(1);
1588
1589
            if ($ctx['sessionId'] > 0) {
1590
                $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
1591
            } else {
1592
                $qb->andWhere('COALESCE(w.sessionId,0) = 0');
1593
            }
1594
1595
            $last = $qb->getQuery()->getOneOrNullResult();
1596
        }
1597
1598
        if ($last && $last->getPageId()) {
1599
            Event::addEvent(LOG_WIKI_ACCESS, LOG_WIKI_PAGE_ID, (int)$last->getPageId());
1600
            $last->setHits(((int)$last->getHits()) + 1);
1601
            $em->flush();
1602
        }
1603
1604
        $content = '';
1605
        $title   = '';
1606
1607
        if (!$last || ($last->getContent() === '' && $last->getTitle() === '' && $pageKey === 'index')) {
1608
            $groupInfo = GroupManager::get_group_properties((int)$ctx['groupId']);
1609
            if (api_is_allowed_to_edit(false, true)
1610
                || api_is_platform_admin()
1611
                || GroupManager::is_user_in_group(api_get_user_id(), $groupInfo)
1612
                || api_is_allowed_in_course()
1613
            ) {
1614
                $content = '<div class="text-center">'
1615
                    .sprintf(get_lang('Default content'), api_get_path(WEB_IMG_PATH))
1616
                    .'</div>';
1617
                $title = get_lang('Home');
1618
            } else {
1619
                Display::addFlash(Display::return_message(get_lang('Wiki stand by'), 'normal', false));
1620
                return null;
1621
            }
1622
        } else {
1623
            if (true === api_get_configuration_value('wiki_html_strict_filtering')) {
1624
                $content = Security::remove_XSS($last->getContent(), COURSEMANAGERLOWSECURITY);
1625
            } else {
1626
                $content = Security::remove_XSS($last->getContent());
1627
            }
1628
            $title = htmlspecialchars_decode(Security::remove_XSS($last->getTitle()));
1629
        }
1630
1631
        // Badges next to title
1632
        $pageTitleText = self::displayTitleFor($pageKey, $last ? $last->getTitle() : null);
1633
        $pageTitle     = api_htmlentities($pageTitleText);
1634
        if ($last) {
1635
            $badges  = '';
1636
            $assign  = (int) $last->getAssignment();
1637
1638
            if ($assign === 1) {
1639
                $badges .= Display::getMdiIcon(
1640
                    ActionIcon::WIKI_ASSIGNMENT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Assignment desc extra')
1641
                );
1642
            } elseif ($assign === 2) {
1643
                $badges .= Display::getMdiIcon(
1644
                    ActionIcon::WIKI_WORK, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Assignment work')
1645
                );
1646
            }
1647
1648
            // Task badge (if any)
1649
            $hasTask = self::confRepo()->findOneBy([
1650
                'cId'    => $ctx['courseId'],
1651
                'pageId' => (int) $last->getPageId(),
1652
            ]);
1653
            if ($hasTask && $hasTask->getTask()) {
1654
                $badges .= Display::getMdiIcon(
1655
                    ActionIcon::WIKI_TASK, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Standard task')
1656
                );
1657
            }
1658
1659
            if ($badges !== '') {
1660
                $pageTitle = $badges.'&nbsp;'.$pageTitle;
1661
            }
1662
        }
1663
1664
        // Visibility gate
1665
        if ($keyVisibility != "1"
1666
            && !api_is_allowed_to_edit(false, true)
1667
            && !api_is_platform_admin()
1668
            && ($last?->getAssignment() != 2 || $keyVisibility != "0" || api_get_user_id() != $last?->getUserId())
1669
            && !api_is_allowed_in_course()
1670
        ) {
1671
            return null;
1672
        }
1673
1674
        // Actions (left/right)
1675
        $actionsLeft  = '';
1676
        $actionsRight = '';
1677
1678
        // Edit
1679
        $editLink = '<a href="'.$ctx['baseUrl'].'&action=edit&title='.api_htmlentities(urlencode($pageKey)).'"'
1680
            .self::is_active_navigation_tab('edit').'>'
1681
            .Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Edit')).'</a>';
1682
1683
        $groupInfo = GroupManager::get_group_properties((int)$ctx['groupId']);
1684
        if (api_is_allowed_to_edit(false, true)
1685
            || api_is_allowed_in_course()
1686
            || GroupManager::is_user_in_group(api_get_user_id(), $groupInfo)
1687
        ) {
1688
            $actionsLeft .= $editLink;
1689
        }
1690
1691
        $pageProgress = (int)$last?->getProgress() * 10;
1692
        $pageScore    = (int)$last?->getScore();
1693
1694
        if ($last) {
1695
            // Lock / Unlock
1696
            if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
1697
                $isLocked   = (self::check_protect_page($pageKey, $ctx['courseId'], $ctx['sessionId'], $ctx['groupId']) == 1);
1698
                $lockAction = $isLocked ? 'unlock' : 'lock';
1699
                $lockIcon   = $isLocked ? ActionIcon::LOCK : ActionIcon::UNLOCK;
1700
                $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=showpage&actionpage='.$lockAction
1701
                    .'&title='.api_htmlentities(urlencode($pageKey)).'">'
1702
                    .Display::getMdiIcon($lockIcon, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, $isLocked ? get_lang('Locked') : get_lang('Unlocked'))
1703
                    .'</a>';
1704
            }
1705
1706
            // Visibility
1707
            if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
1708
                $isVisible = (self::check_visibility_page($pageKey, $ctx['courseId'], $ctx['sessionId'], $ctx['groupId']) == 1);
1709
                $visAction = $isVisible ? 'invisible' : 'visible';
1710
                $visIcon   = $isVisible ? ActionIcon::VISIBLE : ActionIcon::INVISIBLE;
1711
                $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=showpage&actionpage='.$visAction
1712
                    .'&title='.api_htmlentities(urlencode($pageKey)).'">'
1713
                    .Display::getMdiIcon($visIcon, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, $isVisible ? get_lang('Hide') : get_lang('Show'))
1714
                    .'</a>';
1715
            }
1716
1717
            // Notify
1718
            if (api_is_allowed_to_session_edit()) {
1719
                $isWatching   = (self::check_notify_page($pageKey) == 1);
1720
                $notifyAction = $isWatching ? 'unlocknotify' : 'locknotify';
1721
                $notifyIcon   = $isWatching ? ActionIcon::SEND_SINGLE_EMAIL : ActionIcon::NOTIFY_OFF;
1722
                $notifyTitle  = $isWatching ? get_lang('CancelNotifyMe') : get_lang('NotifyMe');
1723
                $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=showpage&actionpage='.$notifyAction
1724
                    .'&title='.api_htmlentities(urlencode($pageKey)).'">'
1725
                    .Display::getMdiIcon($notifyIcon, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, $notifyTitle)
1726
                    .'</a>';
1727
            }
1728
1729
            // Discuss
1730
            if ((api_is_allowed_to_session_edit(false, true) && api_is_allowed_to_edit())
1731
                || GroupManager::is_user_in_group(api_get_user_id(), $groupInfo)
1732
            ) {
1733
                $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=discuss&title='
1734
                    .api_htmlentities(urlencode($pageKey)).'" '.self::is_active_navigation_tab('discuss').'>'
1735
                    .Display::getMdiIcon(ActionIcon::COMMENT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Discuss this page'))
1736
                    .'</a>';
1737
            }
1738
1739
            // History
1740
            $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=history&title='
1741
                .api_htmlentities(urlencode($pageKey)).'" '.self::is_active_navigation_tab('history').'>'
1742
                .Display::getMdiIcon(ActionIcon::HISTORY, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Show page history'))
1743
                .'</a>';
1744
1745
            // Links
1746
            $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=links&title='
1747
                .api_htmlentities(urlencode($pageKey)).'" '.self::is_active_navigation_tab('links').'>'
1748
                .Display::getMdiIcon(ActionIcon::LINKS, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Links pages'))
1749
                .'</a>';
1750
1751
            // Delete
1752
            if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
1753
                $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=delete&title='
1754
                    .api_htmlentities(urlencode($pageKey)).'"'.self::is_active_navigation_tab('delete').'>'
1755
                    .Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Delete'))
1756
                    .'</a>';
1757
            }
1758
1759
            // Export
1760
            if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
1761
                $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=export2doc&wiki_id='.$last->getIid().'">'
1762
                    .Display::getMdiIcon(ActionIcon::EXPORT_DOC, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export'))
1763
                    .'</a>';
1764
            }
1765
            $actionsRight .= '<a href="'.$ctx['baseUrl'].'&action=export_to_pdf&wiki_id='.$last->getIid().'">'
1766
                .Display::getMdiIcon(ActionIcon::EXPORT_PDF, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export'))
1767
                .'</a>';
1768
            if (api_get_configuration_value('unoconv.binaries')) {
1769
                $actionsRight .= '<a href="'.$ctx['baseUrl'].'&'.http_build_query(['action' => 'export_to_doc_file', 'id' => $last->getIid()]).'">'
1770
                    .Display::getMdiIcon(ActionIcon::EXPORT_DOC, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export'))
1771
                    .'</a>';
1772
            }
1773
1774
            // Print
1775
            $actionsRight .= '<a href="#" onclick="javascript:(function(){var a=window.open(\'\',\'\',\'width=800,height=600\');a.document.open(\'text/html\');a.document.write($(\'#wikititle\').prop(\'outerHTML\'));a.document.write($(\'#wikicontent\').prop(\'outerHTML\'));a.document.close();a.print();})(); return false;">'
1776
                .Display::getMdiIcon(ActionIcon::PRINT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Print'))
1777
                .'</a>';
1778
        }
1779
1780
        // Classic top bar
1781
        $contentHtml = self::v1ToolbarHtml($actionsLeft, $actionsRight);
1782
1783
        // Link post-processing
1784
        $pageWiki = self::detect_news_link($content);
1785
        $pageWiki = self::detect_irc_link($pageWiki);
1786
        $pageWiki = self::detect_ftp_link($pageWiki);
1787
        $pageWiki = self::detect_mail_link($pageWiki);
1788
        $pageWiki = self::detect_anchor_link($pageWiki);
1789
        $pageWiki = self::detect_external_link($pageWiki);
1790
        $pageWiki = self::make_wiki_link_clickable($pageWiki, $ctx['baseUrl']);
1791
1792
        // Footer meta + categories
1793
        $footerMeta =
1794
            '<span>'.get_lang('Progress').': '.$pageProgress.'%</span> '.
1795
            '<span>'.get_lang('Rating').': '.$pageScore.'</span> '.
1796
            '<span>'.get_lang('Words').': '.self::word_count($content).'</span>';
1797
1798
        $categories = self::returnCategoriesBlock(
1799
            (int)($last?->getIid() ?? 0),
1800
            '<div class="wiki-catwrap">',
1801
            '</div>'
1802
        );
1803
1804
        // Classic shell + new helper classes
1805
        $contentHtml .=
1806
            '<div id="tool-wiki" class="wiki-root">'.
1807
            '<div id="mainwiki" class="wiki-wrap">'.
1808
            '  <div id="wikititle" class="wiki-card wiki-title"><h1>'.$pageTitle.'</h1></div>'.
1809
            '  <div id="wikicontent" class="wiki-card wiki-prose">'.$pageWiki.'</div>'.
1810
            '  <div id="wikifooter" class="wiki-card wiki-footer">'.
1811
            '       <div class="meta">'.$footerMeta.'</div>'.$categories.
1812
            '  </div>'.
1813
            '</div>'.
1814
            '</div>';
1815
1816
        return $contentHtml;
1817
    }
1818
1819
    private static function v1ToolbarHtml(string $left, string $right): string
1820
    {
1821
        if ($left === '' && $right === '') {
1822
            return '';
1823
        }
1824
1825
        return
1826
            '<div class="wiki-actions" style="display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid #ddd;border-radius:4px;background:#fff">'.
1827
            '  <div class="wiki-actions-left" style="display:inline-flex;gap:6px">'.$left.'</div>'.
1828
            '  <div class="wiki-actions-right" style="display:inline-flex;gap:6px;margin-left:auto">'.$right.'</div>'.
1829
            '</div>';
1830
    }
1831
1832
    /** Render category links of a page as search filters. */
1833
    private static function returnCategoriesBlock(int $wikiId, string $tagStart = '<div>', string $tagEnd = '</div>'): string
1834
    {
1835
        if (!self::categoriesEnabled() || $wikiId <= 0) {
1836
            return '';
1837
        }
1838
1839
        try {
1840
            $em = Container::getEntityManager();
1841
            /** @var CWiki|null $wiki */
1842
            $wiki = $em->find(CWiki::class, $wikiId);
1843
            if (!$wiki) { return ''; }
1844
        } catch (\Throwable $e) {
1845
            return '';
1846
        }
1847
1848
        $baseUrl = self::ctx()['baseUrl'];
1849
1850
        $links = [];
1851
        foreach ($wiki->getCategories()->getValues() as $category) {
1852
            /** @var CWikiCategory $category */
1853
            $urlParams = [
1854
                'search_term'      => isset($_GET['search_term']) ? Security::remove_XSS($_GET['search_term']) : '',
1855
                'SubmitWikiSearch' => '',
1856
                '_qf__wiki_search' => '',
1857
                'action'           => 'searchpages',
1858
                'categories'       => ['' => $category->getId()],
1859
            ];
1860
            $href  = $baseUrl.'&'.http_build_query($urlParams);
1861
            $label = api_htmlentities($category->getName());
1862
            $links[] = self::twCategoryPill($href, $label);
1863
        }
1864
1865
        if (empty($links)) {
1866
            return '';
1867
        }
1868
1869
        return $tagStart.implode('', $links).$tagEnd;
1870
    }
1871
1872
    /** Active class helper for toolbar tabs. */
1873
    public static function is_active_navigation_tab($paramwk)
1874
    {
1875
        if (isset($_GET['action']) && $_GET['action'] == $paramwk) {
1876
            return ' class="active"';
1877
        }
1878
        return '';
1879
    }
1880
1881
    /** Return 1 if current user is subscribed to page notifications, else 0 (also processes toggles). */
1882
    public static function check_notify_page(string $reflink, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): int
1883
    {
1884
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
1885
        $conn = Container::getEntityManager()->getConnection();
1886
1887
        $cid = (int)$ctx['courseId'];
1888
        $gid = (int)$ctx['groupId'];
1889
        $sid = (int)$ctx['sessionId'];
1890
        $uid = (int)api_get_user_id();
1891
1892
        $watchKey = 'watch:'.self::normalizeReflink($reflink);
1893
1894
        $count = (int)$conn->fetchOne(
1895
            'SELECT COUNT(*)
1896
           FROM c_wiki_mailcue
1897
          WHERE c_id = :cid
1898
            AND COALESCE(group_id,0) = :gid
1899
            AND '.($sid > 0 ? 'COALESCE(session_id,0) = :sid' : 'COALESCE(session_id,0) = 0').'
1900
            AND user_id = :uid
1901
            AND type = :t',
1902
            ['cid'=>$cid,'gid'=>$gid,'sid'=>$sid,'uid'=>$uid,'t'=>$watchKey]
1903
        );
1904
1905
        return $count > 0 ? 1 : 0;
1906
    }
1907
1908
    /** Word count from HTML (UTF-8 safe). */
1909
    private static function word_count(string $html): int
1910
    {
1911
        $text = html_entity_decode(strip_tags($html), ENT_QUOTES, 'UTF-8');
1912
        $text = preg_replace('/\s+/u', ' ', trim($text));
1913
        if ($text === '') {
1914
            return 0;
1915
        }
1916
        $tokens = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
1917
        return is_array($tokens) ? count($tokens) : 0;
1918
    }
1919
1920
    /** True if any row with this title exists in the context. */
1921
    public static function wiki_exist(
1922
        string $reflink,
1923
        ?int $courseId = null,
1924
        ?int $sessionId = null,
1925
        ?int $groupId = null
1926
    ): bool {
1927
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
1928
        $repo = self::repo();
1929
1930
        // Ensure canonicalization (Home/Main_Page → index, lowercase, etc.)
1931
        $reflink = self::normalizeReflink($reflink);
1932
1933
        $qb = $repo->createQueryBuilder('w')
1934
            ->select('w.iid')
1935
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1936
            ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
1937
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1938
            ->setMaxResults(1);
1939
1940
        if ($ctx['sessionId'] > 0) {
1941
            // In a session: it may exist in 0 (global) or in the current session
1942
            $qb->andWhere('(COALESCE(w.sessionId,0) = 0 OR w.sessionId = :sid)')
1943
                ->setParameter('sid', (int)$ctx['sessionId']);
1944
        } else {
1945
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
1946
        }
1947
1948
        return !empty($qb->getQuery()->getArrayResult());
1949
    }
1950
1951
    /** Read/toggle global addlock; returns current value or null. */
1952
    public static function check_addnewpagelock(?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): ?int
1953
    {
1954
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
1955
        $em   = Container::getEntityManager();
1956
        $repo = self::repo();
1957
1958
        /** @var CWiki|null $row */
1959
        $row = $repo->createQueryBuilder('w')
1960
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1961
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1962
            ->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId'])
1963
            ->orderBy('w.version', 'ASC')
1964
            ->setMaxResults(1)
1965
            ->getQuery()->getOneOrNullResult();
1966
1967
        $status = $row ? (int)$row->getAddlock() : null;
1968
1969
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
1970
            if (isset($_GET['actionpage'])) {
1971
                if ($_GET['actionpage'] === 'lockaddnew' && $status === 1) {
1972
                    $status = 0;
1973
                } elseif ($_GET['actionpage'] === 'unlockaddnew' && $status === 0) {
1974
                    $status = 1;
1975
                }
1976
1977
                $em->createQuery('UPDATE Chamilo\CourseBundle\Entity\CWiki w SET w.addlock = :v WHERE w.cId = :cid AND COALESCE(w.groupId,0) = :gid AND COALESCE(w.sessionId,0) = :sid')
1978
                    ->setParameter('v', $status)
1979
                    ->setParameter('cid', $ctx['courseId'])
1980
                    ->setParameter('gid', (int)$ctx['groupId'])
1981
                    ->setParameter('sid', (int)$ctx['sessionId'])
1982
                    ->execute();
1983
1984
                $row = $repo->createQueryBuilder('w')
1985
                    ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
1986
                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
1987
                    ->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId'])
1988
                    ->orderBy('w.version', 'ASC')
1989
                    ->setMaxResults(1)
1990
                    ->getQuery()->getOneOrNullResult();
1991
1992
                return $row ? (int)$row->getAddlock() : null;
1993
            }
1994
        }
1995
1996
        return $status;
1997
    }
1998
1999
    /** Read/toggle editlock by page (reflink); returns current status (0/1). */
2000
    public static function check_protect_page(string $page, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): int
2001
    {
2002
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
2003
        $em   = Container::getEntityManager();
2004
        $repo = self::repo();
2005
2006
        /** @var CWiki|null $row */
2007
        $row = $repo->createQueryBuilder('w')
2008
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
2009
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
2010
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
2011
            ->orderBy('w.version', 'ASC')
2012
            ->setMaxResults(1)
2013
            ->getQuery()->getOneOrNullResult();
2014
2015
        if (!$row) {
2016
            return 0;
2017
        }
2018
2019
        $status = (int)$row->getEditlock();
2020
        $pid    = (int)$row->getPageId();
2021
2022
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
2023
            if (!empty($_GET['actionpage'])) {
2024
                if ($_GET['actionpage'] === 'lock' && $status === 0) {
2025
                    $status = 1;
2026
                } elseif ($_GET['actionpage'] === 'unlock' && $status === 1) {
2027
                    $status = 0;
2028
                }
2029
2030
                $em->createQuery('UPDATE Chamilo\CourseBundle\Entity\CWiki w SET w.editlock = :v WHERE w.cId = :cid AND w.pageId = :pid')
2031
                    ->setParameter('v', $status)
2032
                    ->setParameter('cid', $ctx['courseId'])
2033
                    ->setParameter('pid', $pid)
2034
                    ->execute();
2035
2036
                $row = $repo->createQueryBuilder('w')
2037
                    ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
2038
                    ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
2039
                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
2040
                    ->orderBy('w.version', 'ASC')
2041
                    ->setMaxResults(1)
2042
                    ->getQuery()->getOneOrNullResult();
2043
            }
2044
        }
2045
2046
        return (int)($row?->getEditlock() ?? 0);
2047
    }
2048
2049
    /** Read/toggle visibility by page (reflink); returns current status (0/1). */
2050
    public static function check_visibility_page(string $page, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): int
2051
    {
2052
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
2053
        $em   = Container::getEntityManager();
2054
        $repo = self::repo();
2055
2056
        /** @var CWiki|null $row */
2057
        $row = $repo->createQueryBuilder('w')
2058
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
2059
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
2060
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
2061
            ->orderBy('w.version', 'ASC')
2062
            ->setMaxResults(1)
2063
            ->getQuery()->getOneOrNullResult();
2064
2065
        if (!$row) {
2066
            return 0;
2067
        }
2068
2069
        $status = (int)$row->getVisibility();
2070
2071
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
2072
            if (!empty($_GET['actionpage'])) {
2073
                if ($_GET['actionpage'] === 'visible' && $status === 0) {
2074
                    $status = 1;
2075
                } elseif ($_GET['actionpage'] === 'invisible' && $status === 1) {
2076
                    $status = 0;
2077
                }
2078
2079
                $em->createQuery('UPDATE Chamilo\CourseBundle\Entity\CWiki w SET w.visibility = :v WHERE w.cId = :cid AND w.reflink = :r AND COALESCE(w.groupId,0) = :gid')
2080
                    ->setParameter('v', $status)
2081
                    ->setParameter('cid', $ctx['courseId'])
2082
                    ->setParameter('r', html_entity_decode($page))
2083
                    ->setParameter('gid', (int)$ctx['groupId'])
2084
                    ->execute();
2085
2086
                $row = $repo->createQueryBuilder('w')
2087
                    ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
2088
                    ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
2089
                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
2090
                    ->orderBy('w.version', 'ASC')
2091
                    ->setMaxResults(1)
2092
                    ->getQuery()->getOneOrNullResult();
2093
            }
2094
        }
2095
2096
        return (int)($row?->getVisibility() ?? 1);
2097
    }
2098
2099
    private static function toDateTime(null|string|\DateTime $v): ?\DateTime
2100
    {
2101
        if ($v instanceof \DateTime) {
2102
            return $v;
2103
        }
2104
        if (is_string($v) && $v !== '') {
2105
            try { return new \DateTime($v); } catch (\Throwable) {}
2106
        }
2107
        return null;
2108
    }
2109
2110
    /** Extract [[wikilinks]] → space-separated normalized reflinks. */
2111
    private static function links_to(string $input): string
2112
    {
2113
        $parts = preg_split("/(\[\[|]])/", $input, -1, PREG_SPLIT_DELIM_CAPTURE);
2114
        $out = [];
2115
        foreach ($parts as $k => $v) {
2116
            if (($parts[$k-1] ?? null) === '[[' && ($parts[$k+1] ?? null) === ']]') {
2117
                if (api_strpos($v, '|') !== false) {
2118
                    [$link] = explode('|', $v, 2);
2119
                    $link = trim($link);
2120
                } else {
2121
                    $link = trim($v);
2122
                }
2123
                $out[] = Database::escape_string(str_replace(' ', '_', $link)).' ';
2124
            }
2125
        }
2126
        return implode($out);
2127
    }
2128
2129
    private static function detect_external_link(string $input): string
2130
    {
2131
        return str_replace('href=', 'class="wiki_link_ext" href=', $input);
2132
    }
2133
    private static function detect_anchor_link(string $input): string
2134
    {
2135
        return str_replace('href="#', 'class="wiki_anchor_link" href="#', $input);
2136
    }
2137
    private static function detect_mail_link(string $input): string
2138
    {
2139
        return str_replace('href="mailto', 'class="wiki_mail_link" href="mailto', $input);
2140
    }
2141
    private static function detect_ftp_link(string $input): string
2142
    {
2143
        return str_replace('href="ftp', 'class="wiki_ftp_link" href="ftp', $input);
2144
    }
2145
    private static function detect_news_link(string $input): string
2146
    {
2147
        return str_replace('href="news', 'class="wiki_news_link" href="news', $input);
2148
    }
2149
    private static function detect_irc_link(string $input): string
2150
    {
2151
        return str_replace('href="irc', 'class="wiki_irc_link" href="irc', $input);
2152
    }
2153
2154
    /** Convert [[Page|Title]] to <a> depending on existence. */
2155
    private static function make_wiki_link_clickable(string $input, string $baseUrl): string
2156
    {
2157
        $parts = preg_split("/(\[\[|]])/", $input, -1, PREG_SPLIT_DELIM_CAPTURE);
2158
2159
        foreach ($parts as $k => $v) {
2160
            if (($parts[$k-1] ?? null) === '[[' && ($parts[$k+1] ?? null) === ']]') {
2161
                if (api_strpos($v, '|') !== false) {
2162
                    [$rawLink, $title] = explode('|', $v, 2);
2163
                    $rawLink = trim(strip_tags($rawLink));
2164
                    $title   = trim($title);
2165
                } else {
2166
                    $rawLink = trim(strip_tags($v));
2167
                    $title   = trim($v);
2168
                }
2169
2170
                $reflink = self::normalizeReflink($rawLink);
2171
                if (self::isMain($reflink)) {
2172
                    $title = self::displayTitleFor('index');
2173
                }
2174
2175
                if (self::checktitle($reflink)) {
2176
                    $href = $baseUrl.'&action=showpage&title='.urlencode($reflink);
2177
                    $parts[$k] = '<a href="'.$href.'" class="wiki_link">'.$title.'</a>';
2178
                } else {
2179
                    $href = $baseUrl.'&action=addnew&title='.Security::remove_XSS(urlencode($reflink));
2180
                    $parts[$k] = '<a href="'.$href.'" class="new_wiki_link">'.$title.'</a>';
2181
                }
2182
2183
                unset($parts[$k-1], $parts[$k+1]);
2184
            }
2185
        }
2186
        return implode('', $parts);
2187
    }
2188
2189
    private static function assignCategoriesToWiki(CWiki $wiki, array $categoriesIdList): void
2190
    {
2191
        if (!self::categoriesEnabled()) {
2192
            return;
2193
        }
2194
2195
        $em = Container::getEntityManager();
2196
2197
        foreach ($categoriesIdList as $categoryId) {
2198
            if (!$categoryId) {
2199
                continue;
2200
            }
2201
            /** @var CWikiCategory|null $category */
2202
            $category = $em->find(CWikiCategory::class, (int)$categoryId);
2203
            if ($category) {
2204
                if (method_exists($wiki, 'getCategories') && !$wiki->getCategories()->contains($category)) {
2205
                    $wiki->addCategory($category);
2206
                } else {
2207
                    $wiki->addCategory($category);
2208
                }
2209
            }
2210
        }
2211
2212
        $em->flush();
2213
    }
2214
2215
    private static function twToolbarHtml(string $leftHtml, string $rightHtml = ''): string
2216
    {
2217
        $wrap = 'flex items-center gap-2 [&_a]:inline-flex [&_a]:items-center [&_a]:gap-2 [&_a]:rounded-lg [&_a]:border [&_a]:border-slate-200 [&_a]:bg-white [&_a]:px-3 [&_a]:py-1.5 [&_a]:text-sm [&_a]:font-medium [&_a]:text-slate-700 [&_a]:shadow-sm hover:[&_a]:shadow';
2218
        return '<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">'.
2219
            '<div class="'.$wrap.'">'.$leftHtml.'</div>'.
2220
            '<div class="'.$wrap.'">'.$rightHtml.'</div>'.
2221
            '</div>';
2222
    }
2223
2224
    private static function twPanel(string $body, string $title = '', string $footer = ''): string
2225
    {
2226
        $html = '<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">';
2227
        if ($title !== '') {
2228
            $html .= '<div class="border-b border-slate-200 px-5 py-4 text-lg font-semibold text-slate-800">'.$title.'</div>';
2229
        }
2230
        $html .= '<div class="px-5 py-6 leading-relaxed text-slate-700">'.$body.'</div>';
2231
        if ($footer !== '') {
2232
            $html .= '<div class="border-t border-slate-200 px-5 py-3 text-sm text-slate-600">'.$footer.'</div>';
2233
        }
2234
        $html .= '</div>';
2235
        return $html;
2236
    }
2237
2238
    /** Category pill link */
2239
    private static function twCategoryPill(string $href, string $label): string
2240
    {
2241
        return '<a href="'.$href.'" class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700 hover:bg-slate-200">'.$label.'</a>';
2242
    }
2243
2244
    /** Convert DateTime|string|int|null to timestamp. */
2245
    private static function toTimestamp($v): int
2246
    {
2247
        if ($v instanceof \DateTimeInterface) {
2248
            return $v->getTimestamp();
2249
        }
2250
        if (is_int($v)) {
2251
            return $v;
2252
        }
2253
        if (is_string($v) && $v !== '') {
2254
            $t = strtotime($v);
2255
            if ($t !== false) {
2256
                return $t;
2257
            }
2258
        }
2259
        return time();
2260
    }
2261
2262
    public function display_new_wiki_form(): void
2263
    {
2264
        $ctx  = self::ctx();
2265
        $url  = $ctx['baseUrl'].'&'.http_build_query(['action' => 'addnew']);
2266
        $form = new FormValidator('wiki_new', 'post', $url);
2267
2268
        // Required title
2269
        $form->addElement('text', 'title', get_lang('Title'));
2270
        $form->addRule('title', get_lang('ThisFieldIsRequired'), 'required');
2271
2272
        // Editor and advanced fields (adds a hidden wpost_id inside if your setForm doesn’t)
2273
        self::setForm($form);
2274
2275
        // Ensure there is a wpost_id for double_post()
2276
        if (!$form->elementExists('wpost_id')) {
2277
            $form->addElement('hidden', 'wpost_id', api_get_unique_id());
2278
        }
2279
2280
        // Prefill if ?title= is present
2281
        $titleFromGet = isset($_GET['title']) ? htmlspecialchars_decode(Security::remove_XSS((string) $_GET['title'])) : '';
2282
        $form->setDefaults(['title' => $titleFromGet]);
2283
2284
        // --- Process first (don’t output yet) ---
2285
        if ($form->validate()) {
2286
            $values = $form->exportValues();
2287
2288
            // Consistent dates (if provided)
2289
            $toTs = static function ($v): ?int {
2290
                if ($v instanceof \DateTimeInterface) { return $v->getTimestamp(); }
2291
                if (is_string($v) && $v !== '')      { return strtotime($v); }
2292
                return null;
2293
            };
2294
            $startTs = isset($values['startdate_assig']) ? $toTs($values['startdate_assig']) : null;
2295
            $endTs   = isset($values['enddate_assig'])   ? $toTs($values['enddate_assig'])   : null;
2296
2297
            if ($startTs && $endTs && $startTs > $endTs) {
2298
                Display::addFlash(Display::return_message(get_lang('EndDateCannotBeBeforeStartDate'), 'error', false));
2299
                // show the form again
2300
                $form->display();
2301
                return;
2302
            }
2303
2304
            // Anti double-post (if wpost is missing, don’t block)
2305
            if (isset($values['wpost_id']) && !self::double_post($values['wpost_id'])) {
2306
                // Duplicate: go back without saving
2307
                Display::addFlash(Display::return_message(get_lang('DuplicateSubmissionIgnored'), 'warning', false));
2308
                $form->display();
2309
                return;
2310
            }
2311
2312
            // If “assignment for all” => generate per user (if needed)
2313
            if (!empty($values['assignment']) && (int)$values['assignment'] === 1) {
2314
                // If your implementation needs it, keep it; otherwise omit for now.
2315
                // self::auto_add_page_users($values);
2316
            }
2317
2318
            // Save: use our robust helper
2319
            $msg = self::save_new_wiki($values);
2320
            if ($msg === false) {
2321
                Display::addFlash(Display::return_message(get_lang('NoWikiPageTitle'), 'error', false));
2322
                $form->display();
2323
                return;
2324
            }
2325
2326
            Display::addFlash(Display::return_message($msg, 'confirmation', false));
2327
2328
            // Redirect to the created page (no output beforehand)
2329
            $wikiData    = self::getWikiData();
2330
            $redirRef    = self::normalizeReflink($wikiData['reflink'] ?? self::normalizeReflink($values['title'] ?? 'index'));
2331
            $redirectUrl = $ctx['baseUrl'].'&'.http_build_query(['action' => 'showpage', 'title' => $redirRef]);
2332
            header('Location: '.$redirectUrl);
2333
            exit;
2334
        }
2335
2336
        // --- Show form (GET or invalid POST) ---
2337
        $form->addButtonSave(get_lang('Save'), 'SaveWikiNew');
2338
        $form->display();
2339
    }
2340
2341
    public function getHistory(): void
2342
    {
2343
        $page = (string) $this->page;
2344
2345
        if (empty($_GET['title'])) {
2346
            Display::addFlash(Display::return_message(get_lang('Must select a page'), 'error', false));
2347
            return;
2348
        }
2349
2350
        $ctx  = self::ctx();
2351
        $repo = self::repo();
2352
2353
        // Latest version (for visibility/ownership)
2354
        $qbLast = $repo->createQueryBuilder('w')
2355
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
2356
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
2357
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
2358
            ->orderBy('w.version', 'DESC')->setMaxResults(1);
2359
2360
        if ($ctx['sessionId'] > 0) {
2361
            $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
2362
        } else {
2363
            $qbLast->andWhere('COALESCE(w.sessionId,0) = 0');
2364
        }
2365
2366
        /** @var CWiki|null $last */
2367
        $last = $qbLast->getQuery()->getOneOrNullResult();
2368
2369
        $keyVisibility = $last?->getVisibility();
2370
        $keyAssignment = $last?->getAssignment();
2371
        $keyTitle      = $last?->getTitle();
2372
        $keyUserId     = $last?->getUserId();
2373
2374
        // Permissions
2375
        $userId = api_get_user_id();
2376
        $canSee =
2377
            $keyVisibility == 1 ||
2378
            api_is_allowed_to_edit(false, true) ||
2379
            api_is_platform_admin() ||
2380
            ($keyAssignment == 2 && $keyVisibility == 0 && $userId == $keyUserId);
2381
2382
        if (!$canSee) {
2383
            Display::addFlash(Display::return_message(get_lang('Not allowed'), 'error', false));
2384
            return;
2385
        }
2386
2387
        // All versions (DESC)
2388
        $qbAll = $repo->createQueryBuilder('w')
2389
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
2390
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
2391
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
2392
            ->orderBy('w.version', 'DESC');
2393
2394
        if ($ctx['sessionId'] > 0) {
2395
            $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
2396
        } else {
2397
            $qbAll->andWhere('COALESCE(w.sessionId,0) = 0');
2398
        }
2399
2400
        /** @var CWiki[] $versions */
2401
        $versions = $qbAll->getQuery()->getResult();
2402
2403
        // Assignment icon
2404
        $icon = null;
2405
        if ((int)$keyAssignment === 1) {
2406
            $icon = Display::return_icon('wiki_assignment.png', get_lang('Assignment desc extra'), '', ICON_SIZE_SMALL);
2407
        } elseif ((int)$keyAssignment === 2) {
2408
            $icon = Display::return_icon('wiki_work.png', get_lang('Assignment work extra'), '', ICON_SIZE_SMALL);
2409
        }
2410
2411
        // View 1: pick two versions
2412
        if (!isset($_POST['HistoryDifferences']) && !isset($_POST['HistoryDifferences2'])) {
2413
            $title = (string) $_GET['title'];
2414
2415
            echo '<div id="wikititle">'.($icon ? $icon.'&nbsp;&nbsp;&nbsp;' : '').api_htmlentities($keyTitle ?? '').'</div>';
2416
2417
            $actionUrl = self::ctx()['baseUrl'].'&'.http_build_query(['action' => 'history', 'title' => api_htmlentities($title)]);
2418
            echo '<form id="differences" method="POST" action="'.$actionUrl.'">';
2419
            echo '<ul style="list-style-type:none">';
2420
            echo '<br />';
2421
            echo '<button class="search" type="submit" name="HistoryDifferences" value="HistoryDifferences">'.get_lang('Show differences').' '.get_lang('Lines diff').'</button> ';
2422
            echo '<button class="search" type="submit" name="HistoryDifferences2" value="HistoryDifferences2">'.get_lang('Show differences').' '.get_lang('Words diff').'</button>';
2423
            echo '<br /><br />';
2424
2425
            $total = count($versions);
2426
            foreach ($versions as $i => $w) {
2427
                $ui = api_get_user_info((int)$w->getUserId());
2428
                $username = $ui ? api_htmlentities(sprintf(get_lang('LoginX'), $ui['username']), ENT_QUOTES) : get_lang('Anonymous');
2429
2430
                $oldStyle   = ($i === 0)         ? 'style="visibility:hidden;"' : '';
2431
                $newChecked = ($i === 0)         ? ' checked' : '';
2432
                $newStyle   = ($i === $total -1) ? 'style="visibility:hidden;"' : '';
2433
                $oldChecked = ($i === 1)         ? ' checked' : '';
2434
2435
                $dtime = $w->getDtime() ? $w->getDtime()->format('Y-m-d H:i:s') : '';
2436
                $comment = (string) $w->getComment();
2437
                $commentShort = $comment !== '' ? api_htmlentities(api_substr($comment, 0, 100)) : '---';
2438
                $needsDots    = (api_strlen($comment) > 100) ? '...' : '';
2439
2440
                echo '<li style="margin-bottom:5px">';
2441
                echo '<input name="old" value="'.$w->getIid().'" type="radio" '.$oldStyle.' '.$oldChecked.'/> ';
2442
                echo '<input name="new" value="'.$w->getIid().'" type="radio" '.$newStyle.' '.$newChecked.'/> ';
2443
                echo '<a href="'.self::ctx()['baseUrl'].'&action=showpage&title='.api_htmlentities(urlencode($page)).'&view='.$w->getIid().'">'.$dtime.'</a> ';
2444
                echo '('.get_lang('Version').' '.(int)$w->getVersion().') ';
2445
                echo get_lang('By').' ';
2446
                if ($ui !== false) {
2447
                    echo UserManager::getUserProfileLink($ui);
2448
                } else {
2449
                    echo $username.' ('.api_htmlentities((string)$w->getUserIp()).')';
2450
                }
2451
                echo ' ( '.get_lang('Progress').': '.api_htmlentities((string)$w->getProgress()).'%, ';
2452
                echo get_lang('Comments').': '.$commentShort.$needsDots.' )';
2453
                echo '</li>';
2454
            }
2455
2456
            echo '<br />';
2457
            echo '<button class="search" type="submit" name="HistoryDifferences" value="HistoryDifferences">'.get_lang('Show differences').' '.get_lang('Lines diff').'</button> ';
2458
            echo '<button class="search" type="submit" name="HistoryDifferences2" value="HistoryDifferences2">'.get_lang('Show differences').' '.get_lang('Words diff').'</button>';
2459
            echo '</ul></form>';
2460
2461
            return;
2462
        }
2463
2464
        // View 2: differences between two versions
2465
        $versionOld = null;
2466
        if (!empty($_POST['old'])) {
2467
            $versionOld = $repo->find((int) $_POST['old']);
2468
        }
2469
        $versionNew = $repo->find((int) $_POST['new']);
2470
2471
        $oldTime    = $versionOld?->getDtime()?->format('Y-m-d H:i:s');
2472
        $oldContent = $versionOld?->getContent();
2473
2474
        if (isset($_POST['HistoryDifferences'])) {
2475
            include 'diff.inc.php';
2476
2477
            echo '<div id="wikititle">'.api_htmlentities((string)$versionNew->getTitle()).'
2478
        <font size="-2"><i>('.get_lang('Differences new').'</i>
2479
        <font style="background-color:#aaaaaa">'.$versionNew->getDtime()?->format('Y-m-d H:i:s').'</font>
2480
        <i>'.get_lang('Differences old').'</i>
2481
        <font style="background-color:#aaaaaa">'.$oldTime.'</font>)
2482
        '.get_lang('Legend').':
2483
        <span class="diffAdded">'.get_lang('Wiki diff added line').'</span>
2484
        <span class="diffDeleted">'.get_lang('Wiki diff deleted line').'</span>
2485
        <span class="diffMoved">'.get_lang('Wiki diff moved line').'</span></font>
2486
    </div>';
2487
2488
            echo '<table>'.diff((string)$oldContent, (string)$versionNew->getContent(), true, 'format_table_line').'</table>';
2489
            echo '<br /><strong>'.get_lang('Legend').'</strong><div class="diff">';
2490
            echo '<table><tr><td></td><td>';
2491
            echo '<span class="diffEqual">'.get_lang('Wiki diff unchanged line').'</span><br />';
2492
            echo '<span class="diffAdded">'.get_lang('Wiki diff added line').'</span><br />';
2493
            echo '<span class="diffDeleted">'.get_lang('Wiki diff deleted line').'</span><br />';
2494
            echo '<span class="diffMoved">'.get_lang('Wiki diff moved line').'</span><br />';
2495
            echo '</td></tr></table>';
2496
        }
2497
2498
        if (isset($_POST['HistoryDifferences2'])) {
2499
            $lines1   = [strip_tags((string)$oldContent)];
2500
            $lines2   = [strip_tags((string)$versionNew->getContent())];
2501
            $diff     = new Text_Diff($lines1, $lines2);
2502
            $renderer = new Text_Diff_Renderer_inline();
2503
2504
            echo '<style>del{background:#fcc}ins{background:#cfc}</style>'.$renderer->render($diff);
2505
            echo '<br /><strong>'.get_lang('Legend').'</strong><div class="diff">';
2506
            echo '<table><tr><td></td><td>';
2507
            echo '<span class="diffAddedTex">'.get_lang('Wiki diff added tex').'</span><br />';
2508
            echo '<span class="diffDeletedTex">'.get_lang('Wiki diff deleted tex').'</span><br />';
2509
            echo '</td></tr></table>';
2510
        }
2511
    }
2512
2513
    public function getLastWikiData($refLink): array
2514
    {
2515
        $ctx  = self::ctx();
2516
        $em   = Container::getEntityManager();
2517
        $repo = $em->getRepository(CWiki::class);
2518
2519
        $qb = $repo->createQueryBuilder('w')
2520
            ->andWhere('w.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
2521
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode((string)$refLink))
2522
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
2523
            ->orderBy('w.version', 'DESC')
2524
            ->setMaxResults(1);
2525
2526
        if ((int)$ctx['sessionId'] > 0) {
2527
            $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
2528
        } else {
2529
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
2530
        }
2531
2532
        /** @var CWiki|null $w */
2533
        $w = $qb->getQuery()->getOneOrNullResult();
2534
        if (!$w) {
2535
            return [];
2536
        }
2537
2538
        // Map to legacy-like keys
2539
        return [
2540
            'iid'        => $w->getIid(),
2541
            'page_id'    => $w->getPageId(),
2542
            'reflink'    => $w->getReflink(),
2543
            'title'      => $w->getTitle(),
2544
            'content'    => $w->getContent(),
2545
            'user_id'    => $w->getUserId(),
2546
            'group_id'   => $w->getGroupId(),
2547
            'dtime'      => $w->getDtime(),
2548
            'addlock'    => $w->getAddlock(),
2549
            'editlock'   => $w->getEditlock(),
2550
            'visibility' => $w->getVisibility(),
2551
            'assignment' => $w->getAssignment(),
2552
            'comment'    => $w->getComment(),
2553
            'progress'   => $w->getProgress(),
2554
            'score'      => $w->getScore(),
2555
            'version'    => $w->getVersion(),
2556
            'is_editing' => $w->getIsEditing(),
2557
            'time_edit'  => $w->getTimeEdit(),
2558
            'hits'       => $w->getHits(),
2559
            'linksto'    => $w->getLinksto(),
2560
            'tag'        => $w->getTag(),
2561
            'user_ip'    => $w->getUserIp(),
2562
            'session_id' => $w->getSessionId(),
2563
        ];
2564
    }
2565
2566
    public function auto_add_page_users($values): void
2567
    {
2568
        $ctx            = self::ctx();
2569
        $assignmentType = (int)($values['assignment'] ?? 0);
2570
2571
        $courseInfo = $ctx['courseInfo'] ?: api_get_course_info_by_id((int)$ctx['courseId']);
2572
        $courseCode = $courseInfo['code'] ?? '';
2573
2574
        $groupId   = (int)$ctx['groupId'];
2575
        $groupInfo = $groupId ? GroupManager::get_group_properties($groupId) : null;
2576
2577
        // Target users: course vs group
2578
        if ($groupId === 0) {
2579
            $users = (int)$ctx['sessionId'] > 0
2580
                ? CourseManager::get_user_list_from_course_code($courseCode, (int)$ctx['sessionId'])
2581
                : CourseManager::get_user_list_from_course_code($courseCode);
2582
        } else {
2583
            $subs   = GroupManager::get_subscribed_users($groupInfo) ?: [];
2584
            $tutors = GroupManager::get_subscribed_tutors($groupInfo) ?: [];
2585
            $byId = [];
2586
            foreach (array_merge($subs, $tutors) as $u) {
2587
                if (!isset($u['user_id'])) { continue; }
2588
                $byId[(int)$u['user_id']] = $u;
2589
            }
2590
            $users = array_values($byId);
2591
        }
2592
2593
        // Teacher data
2594
        $teacherId  = api_get_user_id();
2595
        $tInfo      = api_get_user_info($teacherId);
2596
        $tLogin     = api_htmlentities(sprintf(get_lang('LoginX'), $tInfo['username']), ENT_QUOTES);
2597
        $tName      = $tInfo['complete_name'].' - '.$tLogin;
2598
        $tPhotoUrl  = $tInfo['avatar'] ?? UserManager::getUserPicture($teacherId);
2599
        $tPhoto     = '<img src="'.$tPhotoUrl.'" alt="'.$tName.'" width="40" height="50" align="top" title="'.$tName.'" />';
2600
2601
        $titleOrig    = (string)($values['title'] ?? '');
2602
        $link2teacher = $titleOrig.'_uass'.$teacherId;
2603
2604
        $contentA =
2605
            '<div align="center" style="background-color:#F5F8FB;border:solid;border-color:#E6E6E6">'.
2606
            '<table border="0">'.
2607
            '<tr><td style="font-size:24px">'.get_lang('Assignment desc').'</td></tr>'.
2608
            '<tr><td>'.$tPhoto.'<br />'.Display::tag(
2609
                'span',
2610
                api_get_person_name($tInfo['firstname'], $tInfo['lastname']),
2611
                ['title' => $tLogin]
2612
            ).'</td></tr>'.
2613
            '</table></div>';
2614
2615
        $postedContent = isset($_POST['content']) ? Security::remove_XSS((string)$_POST['content']) : '';
2616
        $contentB = '<br/><div align="center" style="font-size:24px">'.
2617
            get_lang('Assignment description').': '.$titleOrig.'</div><br/>'.
2618
            $postedContent;
2619
2620
        $allStudentsItems = [];
2621
        $postedTitleSafe  = isset($_POST['title']) ? Security::remove_XSS((string)$_POST['title']) : $titleOrig;
2622
2623
        // Create student pages (assignment = 2)
2624
        foreach ($users as $u) {
2625
            $uid = (int)($u['user_id'] ?? 0);
2626
            if ($uid === 0 || $uid === $teacherId) { continue; }
2627
2628
            $uPic   = UserManager::getUserPicture($uid);
2629
            $uLogin = api_htmlentities(sprintf(get_lang('LoginX'), (string)$u['username']), ENT_QUOTES);
2630
            $uName  = api_get_person_name((string)$u['firstname'], (string)$u['lastname']).' . '.$uLogin;
2631
            $uPhoto = '<img src="'.$uPic.'" alt="'.$uName.'" width="40" height="50" align="bottom" title="'.$uName.'" />';
2632
2633
            $isTutor  = $groupInfo && GroupManager::is_tutor_of_group($uid, $groupInfo);
2634
            $isMember = $groupInfo && GroupManager::is_subscribed($uid, $groupInfo);
2635
            $status   = ($isTutor && $isMember) ? get_lang('Group tutor and member')
2636
                : ($isTutor ? get_lang('GroupTutor') : ' ');
2637
2638
            if ($assignmentType === 1) {
2639
                $studentValues               = $values;
2640
                $studentValues['title']      = $titleOrig;
2641
                $studentValues['assignment'] = 2;
2642
                $studentValues['content']    =
2643
                    '<div align="center" style="background-color:#F5F8FB;border:solid;border-color:#E6E6E6">'.
2644
                    '<table border="0">'.
2645
                    '<tr><td style="font-size:24px">'.get_lang('Assignment work').'</td></tr>'.
2646
                    '<tr><td>'.$uPhoto.'<br />'.$uName.'</td></tr>'.
2647
                    '</table></div>'.
2648
                    '[[ '.$link2teacher.' | '.get_lang('Assignment link to teacher page').' ]] ';
2649
2650
                $allStudentsItems[] =
2651
                    '<li>'.
2652
                    Display::tag('span', strtoupper((string)$u['lastname']).', '.(string)$u['firstname'], ['title' => $uLogin]).
2653
                    ' [[ '.$postedTitleSafe.'_uass'.$uid.' | '.$uPhoto.' ]] '.
2654
                    $status.
2655
                    '</li>';
2656
2657
                // Pass the student uid to save_new_wiki so the author is the student
2658
                $this->save_new_wiki($studentValues, $uid);
2659
            }
2660
        }
2661
2662
        // Teacher page (assignment = 1) listing student works
2663
        foreach ($users as $u) {
2664
            if ((int)($u['user_id'] ?? 0) !== $teacherId) { continue; }
2665
2666
            if ($assignmentType === 1) {
2667
                $teacherValues               = $values;
2668
                $teacherValues['title']      = $titleOrig;
2669
                $teacherValues['comment']    = get_lang('Assignment desc');
2670
                sort($allStudentsItems);
2671
2672
                $teacherValues['content'] =
2673
                    $contentA.$contentB.'<br/>'.
2674
                    '<div align="center" style="font-size:18px;background-color:#F5F8FB;border:solid;border-color:#E6E6E6">'.
2675
                    get_lang('AssignmentLinkstoStudentsPage').'</div><br/>'.
2676
                    '<div style="background-color:#F5F8FB;border:solid;border-color:#E6E6E6">'.
2677
                    '<ol>'.implode('', $allStudentsItems).'</ol>'.
2678
                    '</div><br/>';
2679
2680
                $teacherValues['assignment'] = 1;
2681
2682
                // Pass the teacher id so the reflink ends with _uass<teacherId>
2683
                $this->save_new_wiki($teacherValues, $teacherId);
2684
            }
2685
        }
2686
    }
2687
2688
    public function restore_wikipage(
2689
        $r_page_id,
2690
        $r_reflink,
2691
        $r_title,
2692
        $r_content,
2693
        $r_group_id,
2694
        $r_assignment,
2695
        $r_progress,
2696
        $c_version,
2697
        $r_version,
2698
        $r_linksto
2699
    ) {
2700
        $ctx        = self::ctx();
2701
        $_course    = $ctx['courseInfo'];
2702
        $r_user_id  = api_get_user_id();
2703
        $r_dtime    = api_get_utc_datetime(); // string for mail
2704
        $dTime      = api_get_utc_datetime(null, false, true); // DateTime (entity)
2705
2706
        $r_version = ((int)$r_version) + 1;
2707
        $r_comment = get_lang('Restored from version').': '.$c_version;
2708
        $groupInfo = GroupManager::get_group_properties((int)$r_group_id);
2709
2710
        $em = Container::getEntityManager();
2711
2712
        $newWiki = (new CWiki())
2713
            ->setCId((int)$ctx['courseId'])
2714
            ->setPageId((int)$r_page_id)
2715
            ->setReflink((string)$r_reflink)
2716
            ->setTitle((string)$r_title)
2717
            ->setContent((string)$r_content)
2718
            ->setUserId((int)$r_user_id)
2719
            ->setGroupId((int)$r_group_id)
2720
            ->setDtime($dTime)
2721
            ->setAssignment((int)$r_assignment)
2722
            ->setComment((string)$r_comment)
2723
            ->setProgress((int)$r_progress)
2724
            ->setVersion((int)$r_version)
2725
            ->setLinksto((string)$r_linksto)
2726
            ->setUserIp(api_get_real_ip())
2727
            ->setSessionId((int)$ctx['sessionId'])
2728
            ->setAddlock(0)->setEditlock(0)->setVisibility(0)
2729
            ->setAddlockDisc(0)->setVisibilityDisc(0)->setRatinglockDisc(0)
2730
            ->setIsEditing(0)->setTag('');
2731
2732
        $newWiki->setParent($ctx['course']);
2733
        $newWiki->setCreator(api_get_user_entity());
2734
        $groupEntity = $ctx['groupId'] ? api_get_group_entity($ctx['groupId']) : null;
2735
        $newWiki->addCourseLink($ctx['course'], $ctx['session'], $groupEntity);
2736
2737
        $em->persist($newWiki);
2738
        $em->flush();
2739
2740
        api_item_property_update($_course, 'wiki', $newWiki->getIid(), 'WikiAdded', api_get_user_id(), $groupInfo);
2741
        self::check_emailcue((string)$r_reflink, 'P', $r_dtime, (int)$r_user_id);
2742
2743
        return get_lang('Page restored');
2744
    }
2745
2746
    public function restorePage()
2747
    {
2748
        $ctx         = self::ctx();
2749
        $userId      = api_get_user_id();
2750
        $current_row = $this->getWikiData();
2751
        $last_row    = $this->getLastWikiData($this->page);
2752
2753
        if (empty($last_row)) {
2754
            return false;
2755
        }
2756
2757
        $PassEdit = false;
2758
2759
        // Only teacher/admin can edit index or assignment-teacher pages
2760
        if (
2761
            (($current_row['reflink'] ?? '') === 'index' ||
2762
                ($current_row['reflink'] ?? '') === '' ||
2763
                ((int)$current_row['assignment'] === 1)) &&
2764
            (!api_is_allowed_to_edit(false, true) && (int)$ctx['groupId'] === 0)
2765
        ) {
2766
            Display::addFlash(Display::return_message(get_lang('Only edit pages course manager'), 'normal', false));
2767
            return false;
2768
        }
2769
2770
        // Group wiki
2771
        if ((int)($current_row['group_id'] ?? 0) !== 0) {
2772
            $groupInfo = GroupManager::get_group_properties((int)$ctx['groupId']);
2773
            if (api_is_allowed_to_edit(false, true) ||
2774
                api_is_platform_admin() ||
2775
                GroupManager::is_user_in_group($userId, $groupInfo) ||
2776
                api_is_allowed_in_course()
2777
            ) {
2778
                $PassEdit = true;
2779
            } else {
2780
                Display::addFlash(Display::return_message(get_lang('Only edit pages group members'), 'normal', false));
2781
                $PassEdit = false;
2782
            }
2783
        } else {
2784
            $PassEdit = true;
2785
        }
2786
2787
        // Assignment rules
2788
        if ((int)$current_row['assignment'] === 1) {
2789
            Display::addFlash(Display::return_message(get_lang('Edit assignment warning'), 'normal', false));
2790
        } elseif ((int)$current_row['assignment'] === 2) {
2791
            if ((int)$userId !== (int)($current_row['user_id'] ?? 0)) {
2792
                if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
2793
                    $PassEdit = true;
2794
                } else {
2795
                    Display::addFlash(Display::return_message(get_lang('Lock by teacher'), 'normal', false));
2796
                    $PassEdit = false;
2797
                }
2798
            }
2799
        }
2800
2801
        if (!$PassEdit) {
2802
            return false;
2803
        }
2804
2805
        // Edit lock
2806
        if ((int)($current_row['editlock'] ?? 0) === 1 &&
2807
            (!api_is_allowed_to_edit(false, true) || !api_is_platform_admin())
2808
        ) {
2809
            Display::addFlash(Display::return_message(get_lang('Locked'), 'normal', false));
2810
            return false;
2811
        }
2812
2813
        // Concurrency
2814
        $isEditing  = (int)($last_row['is_editing'] ?? 0);
2815
        if ($isEditing !== 0 && $isEditing !== (int)$userId) {
2816
            $timeVal = $last_row['time_edit'] ?? null;
2817
            $ts = $timeVal instanceof \DateTimeInterface ? $timeVal->getTimestamp() : (is_string($timeVal) ? strtotime($timeVal) : time());
2818
            $elapsed = time() - $ts;
2819
            $rest    = max(0, 1200 - $elapsed); // 20 min
2820
2821
            $userinfo = api_get_user_info($isEditing);
2822
            $msg = get_lang('This page is begin edited by').' <a href='.$userinfo['profile_url'].'>'.
2823
                Display::tag('span', $userinfo['complete_name_with_username']).'</a> '.
2824
                get_lang('This page is begin edited try later').' '.date("i", $rest).' '.get_lang('Min minutes');
2825
2826
            Display::addFlash(Display::return_message($msg, 'normal', false));
2827
            return false;
2828
        }
2829
2830
        // Restore (create new version with previous content)
2831
        Display::addFlash(
2832
            Display::return_message(
2833
                self::restore_wikipage(
2834
                    (int)$current_row['page_id'],
2835
                    (string)$current_row['reflink'],
2836
                    (string)$current_row['title'],
2837
                    (string)$current_row['content'],
2838
                    (int)$current_row['group_id'],
2839
                    (int)$current_row['assignment'],
2840
                    (int)$current_row['progress'],
2841
                    (int)$current_row['version'],
2842
                    (int)$last_row['version'],
2843
                    (string)$current_row['linksto']
2844
                ).': '.Display::url(
2845
                    api_htmlentities((string)$last_row['title']),
2846
                    $ctx['baseUrl'].'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities((string)$last_row['reflink'])])
2847
                ),
2848
                'confirmation',
2849
                false
2850
            )
2851
        );
2852
2853
        return true;
2854
    }
2855
2856
    public function handleAction(string $action): void
2857
    {
2858
        $page = $this->page;
2859
        $ctx  = self::ctx();
2860
        $url  = $ctx['baseUrl'];
2861
2862
        // Local renderer for breadcrumb + stylish pills (uniform look)
2863
        $renderStatsHeader = function (string $activeKey) use ($url) {
2864
            static $wikiHdrCssInjected = false;
2865
2866
            // Labels (use existing lang keys)
2867
            $items = [
2868
                'mactiveusers' => get_lang('Most active users'),
2869
                'mvisited'     => get_lang('Most visited pages'),
2870
                'mostchanged'  => get_lang('Most changed pages'),
2871
                'orphaned'     => get_lang('Orphaned pages'),
2872
                'wanted'       => get_lang('Wanted pages'),
2873
                'mostlinked'   => get_lang('Most linked pages'),
2874
                'statistics'   => get_lang('Statistics'),
2875
            ];
2876
2877
            // Simple icon map
2878
            $icons = [
2879
                'mactiveusers' => \Chamilo\CoreBundle\Enums\ActionIcon::STAR,
2880
                'mvisited'     => \Chamilo\CoreBundle\Enums\ActionIcon::HISTORY,
2881
                'mostchanged'  => \Chamilo\CoreBundle\Enums\ActionIcon::REFRESH,
2882
                'orphaned'     => \Chamilo\CoreBundle\Enums\ActionIcon::LINKS,
2883
                'wanted'       => \Chamilo\CoreBundle\Enums\ActionIcon::SEARCH,
2884
                'mostlinked'   => \Chamilo\CoreBundle\Enums\ActionIcon::LINKS,
2885
                'statistics'   => \Chamilo\CoreBundle\Enums\ActionIcon::INFORMATION,
2886
            ];
2887
2888
            if (!$wikiHdrCssInjected) {
2889
                $wikiHdrCssInjected = true;
2890
            }
2891
2892
            $activeLabel = api_htmlentities($items[$activeKey] ?? '');
2893
2894
            echo '<div class="wiki-pills">';
2895
            foreach ($items as $key => $label) {
2896
                $isActive = ($key === $activeKey);
2897
                $href     = $url.'&action='.$key;
2898
                $icon     = Display::getMdiIcon($icons[$key] ?? \Chamilo\CoreBundle\Enums\ActionIcon::VIEW_DETAILS,
2899
                    'mdi-inline', null, ICON_SIZE_SMALL, $label);
2900
                echo '<a class="pill'.($isActive ? ' active' : '').'" href="'.$href.'"'.
2901
                    ($isActive ? ' aria-current="page"' : '').'>'.$icon.'<span>'.api_htmlentities($label).'</span></a>';
2902
            }
2903
            echo '</div>';
2904
        };
2905
2906
        switch ($action) {
2907
            case 'export_to_pdf':
2908
                if (isset($_GET['wiki_id'])) {
2909
                    self::export_to_pdf($_GET['wiki_id'], api_get_course_id());
2910
                    break;
2911
                }
2912
                break;
2913
2914
            case 'export2doc':
2915
                if (isset($_GET['wiki_id'])) {
2916
                    $export2doc = self::export2doc($_GET['wiki_id']);
2917
                    if ($export2doc) {
2918
                        Display::addFlash(
2919
                            Display::return_message(
2920
                                get_lang('ThePageHasBeenExportedToDocArea'),
2921
                                'confirmation',
2922
                                false
2923
                            )
2924
                        );
2925
                    }
2926
                }
2927
                break;
2928
2929
            case 'restorepage':
2930
                self::restorePage();
2931
                break;
2932
2933
            case 'more':
2934
                self::getStatsTable();
2935
                break;
2936
2937
            case 'statistics':
2938
                $renderStatsHeader('statistics');
2939
                self::getStats();
2940
                break;
2941
2942
            case 'mactiveusers':
2943
                $renderStatsHeader('mactiveusers');
2944
                self::getActiveUsers($action);
2945
                break;
2946
2947
            case 'usercontrib':
2948
                self::getUserContributions((int)($_GET['user_id'] ?? 0), $action);
2949
                break;
2950
2951
            case 'mostchanged':
2952
                $renderStatsHeader('mostchanged');
2953
                $this->getMostChangedPages($action);
2954
                break;
2955
2956
            case 'mvisited':
2957
                $renderStatsHeader('mvisited');
2958
                self::getMostVisited();
2959
                break;
2960
2961
            case 'wanted':
2962
                $renderStatsHeader('wanted');
2963
                $this->getWantedPages();
2964
                break;
2965
2966
            case 'orphaned':
2967
                $renderStatsHeader('orphaned');
2968
                self::getOrphaned();
2969
                break;
2970
2971
            case 'mostlinked':
2972
                $renderStatsHeader('mostlinked');
2973
                self::getMostLinked();
2974
                break;
2975
2976
            case 'delete':
2977
                $this->deletePageWarning();
2978
                break;
2979
2980
            case 'deletewiki':
2981
                echo '<nav aria-label="breadcrumb" class="wiki-breadcrumb">
2982
                <ol class="breadcrumb">
2983
                  <li class="breadcrumb-item"><a href="'.
2984
                        $this->url(['action' => 'showpage', 'title' => 'index']).'">'.get_lang('Wiki').'</a></li>
2985
                  <li class="breadcrumb-item active" aria-current="page">'.get_lang('Delete').'</li>
2986
                </ol>
2987
              </nav>';
2988
2989
                echo '<div class="actions">'.get_lang('Delete wiki').'</div>';
2990
2991
                $canDelete     = api_is_allowed_to_edit(false, true) || api_is_platform_admin();
2992
                $confirmedPost = isset($_POST['confirm_delete']) && $_POST['confirm_delete'] === '1';
2993
2994
                if (!$canDelete) {
2995
                    echo Display::return_message(get_lang('Only admin can delete the wiki'), 'error', false);
2996
                    break;
2997
                }
2998
2999
                if (!$confirmedPost) {
3000
                    $actionUrl = $this->url(['action' => 'deletewiki']);
3001
                    $msg  = '<p>'.get_lang('ConfirmDeleteWiki').'</p>';
3002
                    $msg .= '<form method="post" action="'.Security::remove_XSS($actionUrl).'" style="display:inline-block;margin-right:1rem;">';
3003
                    $msg .= '<input type="hidden" name="confirm_delete" value="1">';
3004
                    $msg .= '<button type="submit" class="btn btn-danger">'.get_lang('Yes').'</button>';
3005
                    $msg .= '&nbsp;&nbsp;<a class="btn btn-default" href="'.$this->url().'">'.get_lang('No').'</a>';
3006
                    $msg .= '</form>';
3007
3008
                    echo Display::return_message($msg, 'warning', false);
3009
                    break;
3010
                }
3011
3012
                $summary = self::delete_wiki();
3013
3014
                Display::addFlash(Display::return_message($summary, 'confirmation', false));
3015
                header('Location: '.$this->url());
3016
                exit;
3017
3018
            case 'searchpages':
3019
                self::getSearchPages($action);
3020
                break;
3021
3022
            case 'links':
3023
                self::getLinks($page);
3024
                break;
3025
3026
            case 'addnew':
3027
                if (0 != api_get_session_id() && api_is_allowed_to_session_edit(false, true) == false) {
3028
                    api_not_allowed();
3029
                }
3030
3031
                echo '<div class="actions">'.get_lang('Add new page').'</div>';
3032
                echo '<br/>';
3033
3034
                // Show the tip ONLY if "index" is missing or has no real content
3035
                try {
3036
                    $ctx  = self::ctx();
3037
                    $repo = self::repo();
3038
3039
                    $qb = $repo->createQueryBuilder('w')
3040
                        ->andWhere('w.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
3041
                        ->andWhere('w.reflink = :r')->setParameter('r', 'index')
3042
                        ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3043
                        ->orderBy('w.version', 'DESC')
3044
                        ->setMaxResults(1);
3045
3046
                    if ((int)$ctx['sessionId'] > 0) {
3047
                        $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
3048
                    } else {
3049
                        $qb->andWhere('COALESCE(w.sessionId,0) = 0');
3050
                    }
3051
3052
                    /** @var CWiki|null $indexRow */
3053
                    $indexRow = $qb->getQuery()->getOneOrNullResult();
3054
3055
                    $indexIsEmpty = true;
3056
                    if ($indexRow) {
3057
                        // Decode entities, strip HTML, normalize NBSP and whitespace
3058
                        $raw   = (string)$indexRow->getContent();
3059
                        $text  = api_html_entity_decode($raw, ENT_QUOTES, api_get_system_encoding());
3060
                        $text  = strip_tags($text);
3061
                        $text  = preg_replace('/\xC2\xA0/u', ' ', $text); // NBSP
3062
                        $text  = trim(preg_replace('/\s+/u', ' ', $text));
3063
3064
                        // Consider empty if no letters/digits (handles <p>&nbsp;</p>, placeholders, etc.)
3065
                        $indexIsEmpty = ($text === '' || !preg_match('/[\p{L}\p{N}]/u', $text));
3066
                    }
3067
3068
                    if ($indexIsEmpty && (api_is_allowed_to_edit(false, true) || api_is_platform_admin() || api_is_allowed_in_course())) {
3069
                        Display::addFlash(
3070
                            Display::return_message(get_lang('Go and edit main page'), 'normal', false)
3071
                        );
3072
                    }
3073
                } catch (\Throwable $e) {
3074
                    // If something goes wrong checking content, fail-safe to *not* nag the user.
3075
                }
3076
3077
                // Lock for creating new pages (only affects NON-editors)
3078
                if (self::check_addnewpagelock() == 0
3079
                    && (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin())
3080
                ) {
3081
                    Display::addFlash(
3082
                        Display::return_message(get_lang('Add pages locked'), 'error', false)
3083
                    );
3084
                    break;
3085
                }
3086
3087
                self::display_new_wiki_form();
3088
                break;
3089
3090
            case 'show':
3091
            case 'showpage':
3092
                $requested = self::normalizeReflink($_GET['title'] ?? null);
3093
                echo self::display_wiki_entry($requested, $requested);
3094
                break;
3095
3096
            case 'edit':
3097
                self::editPage();
3098
                break;
3099
3100
            case 'history':
3101
                self::getHistory();
3102
                break;
3103
3104
            case 'recentchanges':
3105
                self::recentChanges($page, $action);
3106
                break;
3107
3108
            case 'allpages':
3109
                self::allPages($action);
3110
                break;
3111
3112
            case 'discuss':
3113
                self::getDiscuss($page);
3114
                break;
3115
3116
            case 'export_to_doc_file':
3117
                self::exportTo($_GET['id'], 'odt');
3118
                exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
3119
                break;
3120
3121
            case 'category':
3122
                $this->addCategory();
3123
                break;
3124
3125
            case 'delete_category':
3126
                $this->deleteCategory();
3127
                break;
3128
        }
3129
    }
3130
3131
    public function showLinks(string $page): void
3132
    {
3133
        $ctx  = self::ctx();
3134
        $repo = self::repo();
3135
3136
        // Basic guard: this action expects a title in the request (legacy behavior)
3137
        if (empty($_GET['title'])) {
3138
            Display::addFlash(Display::return_message(get_lang('Must select a page'), 'error', false));
3139
            return;
3140
        }
3141
3142
        // Canonical reflink for the requested page (logic uses 'index' as main page)
3143
        $reflink = self::normalizeReflink($page);
3144
3145
        // Token used inside "linksto" (UI-friendly, localized with underscores when index)
3146
        $needleToken = self::displayTokenFor($reflink);
3147
3148
        // --- Header block: title + assignment icon (use first version as anchor) ---
3149
        /** @var CWiki|null $first */
3150
        $first = $repo->createQueryBuilder('w')
3151
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3152
            ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
3153
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3154
            ->orderBy('w.version', 'ASC')
3155
            ->setMaxResults(1)
3156
            ->getQuery()->getOneOrNullResult();
3157
3158
        if (!$first) {
3159
            Display::addFlash(Display::return_message(get_lang('Must select a page'), 'error', false));
3160
            return;
3161
        }
3162
3163
        $assignIcon = '';
3164
        if ((int)$first->getAssignment() === 1) {
3165
            $assignIcon = Display::getMdiIcon(ActionIcon::WIKI_ASSIGNMENT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Assignment desc'));
3166
        } elseif ((int)$first->getAssignment() === 2) {
3167
            $assignIcon = Display::getMdiIcon(ActionIcon::WIKI_WORK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Assignment work'));
3168
        }
3169
3170
        echo '<div id="wikititle">'.get_lang('Links pages from').": $assignIcon ".
3171
            Display::url(
3172
                api_htmlentities($first->getTitle()),
3173
                $ctx['baseUrl'].'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities($reflink)])
3174
            ).'</div>';
3175
3176
        // --- Query: latest version per page that *may* link to $needleToken ---
3177
        $qb = $repo->createQueryBuilder('w')
3178
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3179
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3180
            ->andWhere($ctx['sessionId'] > 0 ? '(COALESCE(w.sessionId,0) IN (0, :sid))' : 'COALESCE(w.sessionId,0) = 0')
3181
            ->setParameter('sid', (int)$ctx['sessionId'])
3182
            ->andWhere('w.linksto LIKE :needle')->setParameter('needle', '%'.$needleToken.'%')
3183
            ->andWhere('w.version = (
3184
        SELECT MAX(w2.version) FROM '.CWiki::class.' w2
3185
        WHERE w2.cId = w.cId
3186
          AND w2.pageId = w.pageId
3187
          AND COALESCE(w2.groupId,0) = :gid2
3188
          AND '.($ctx['sessionId'] > 0
3189
                    ? '(COALESCE(w2.sessionId,0) IN (0, :sid2))'
3190
                    : 'COALESCE(w2.sessionId,0) = 0').'
3191
    )')
3192
            ->setParameter('gid2', (int)$ctx['groupId'])
3193
            ->setParameter('sid2', (int)$ctx['sessionId']);
3194
3195
        // Visibility gate for students
3196
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
3197
            $qb->andWhere('w.visibility = 1');
3198
        }
3199
3200
        /** @var CWiki[] $candidates */
3201
        $candidates = $qb->getQuery()->getResult();
3202
3203
        // --- Precise token filter: ensure space-delimited match in "linksto" ---
3204
        $items = [];
3205
        foreach ($candidates as $obj) {
3206
            $tokens = preg_split('/\s+/', trim((string)$obj->getLinksto())) ?: [];
3207
            if (in_array($needleToken, $tokens, true)) {
3208
                $items[] = $obj;
3209
            }
3210
        }
3211
3212
        if (!$items) {
3213
            echo self::twPanel('<em>'.get_lang('No results').'</em>', get_lang('Links pages'));
3214
            return;
3215
        }
3216
3217
        // --- Render simple table ---
3218
        $rowsHtml = '';
3219
        foreach ($items as $obj) {
3220
            $ui = api_get_user_info((int)$obj->getUserId());
3221
            $authorCell = $ui
3222
                ? UserManager::getUserProfileLink($ui)
3223
                : get_lang('Anonymous').' ('.$obj->getUserIp().')';
3224
3225
            $icon = '';
3226
            if ((int)$obj->getAssignment() === 1) {
3227
                $icon = Display::getMdiIcon(ActionIcon::WIKI_ASSIGNMENT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Assignment desc'));
3228
            } elseif ((int)$obj->getAssignment() === 2) {
3229
                $icon = Display::getMdiIcon(ActionIcon::WIKI_WORK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Assignment work'));
3230
            }
3231
3232
            $when = $obj->getDtime() ? api_get_local_time($obj->getDtime()) : '';
3233
            $rowsHtml .= '<tr>'.
3234
                '<td style="width:30px">'.$icon.'</td>'.
3235
                '<td>'.Display::url(
3236
                    api_htmlentities($obj->getTitle()),
3237
                    $ctx['baseUrl'].'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities($obj->getReflink())])
3238
                ).'</td>'.
3239
                '<td>'.$authorCell.'</td>'.
3240
                '<td>'.$when.'</td>'.
3241
                '</tr>';
3242
        }
3243
3244
        $table =
3245
            '<table class="table table-striped">'.
3246
            '<thead><tr>'.
3247
            '<th>'.get_lang('Type').'</th>'.
3248
            '<th>'.get_lang('Title').'</th>'.
3249
            '<th>'.get_lang('Author').'</th>'.
3250
            '<th>'.get_lang('Date').'</th>'.
3251
            '</tr></thead>'.
3252
            '<tbody>'.$rowsHtml.'</tbody>'.
3253
            '</table>';
3254
3255
        echo self::twPanel($table, get_lang('LinksPages'));
3256
    }
3257
3258
    public function showDiscuss(string $page): void
3259
    {
3260
        $ctx  = self::ctx();
3261
        $em   = Container::getEntityManager();
3262
        $repo = self::repo();
3263
3264
        if ($ctx['sessionId'] !== 0 && api_is_allowed_to_session_edit(false, true) === false) {
3265
            api_not_allowed();
3266
        }
3267
3268
        if (empty($_GET['title'])) {
3269
            Display::addFlash(Display::return_message(get_lang('Must select a page'), 'error', false));
3270
            return;
3271
        }
3272
3273
        // FIRST and LAST version (to get properties and page_id)
3274
        /** @var CWiki|null $first */
3275
        $first = $repo->createQueryBuilder('w')
3276
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3277
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
3278
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3279
            ->orderBy('w.version', 'ASC')
3280
            ->setMaxResults(1)
3281
            ->getQuery()->getOneOrNullResult();
3282
3283
        if (!$first) {
3284
            Display::addFlash(Display::return_message(get_lang('Discuss not available'), 'normal', false));
3285
            return;
3286
        }
3287
3288
        $qbLast = $repo->createQueryBuilder('w')
3289
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3290
            ->andWhere('w.pageId = :pid')->setParameter('pid', (int)$first->getPageId())
3291
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3292
            ->orderBy('w.version', 'DESC')
3293
            ->setMaxResults(1);
3294
3295
        if ($ctx['sessionId'] > 0) {
3296
            $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
3297
        } else {
3298
            $qbLast->andWhere('COALESCE(w.sessionId,0) = 0');
3299
        }
3300
3301
        /** @var CWiki|null $last */
3302
        $last = $qbLast->getQuery()->getOneOrNullResult();
3303
        if (!$last) {
3304
            Display::addFlash(Display::return_message(get_lang('Discuss not available'), 'normal', false));
3305
            return;
3306
        }
3307
3308
        // Visibility gate for discussions (like legacy)
3309
        $canSeeDiscuss =
3310
            ((int)$last->getVisibilityDisc() === 1) ||
3311
            api_is_allowed_to_edit(false, true) ||
3312
            api_is_platform_admin() ||
3313
            ((int)$last->getAssignment() === 2 && (int)$last->getVisibilityDisc() === 0 && api_get_user_id() === (int)$last->getUserId());
3314
3315
        if (!$canSeeDiscuss) {
3316
            Display::addFlash(Display::return_message(get_lang('LockByTeacher'), 'warning', false));
3317
            return;
3318
        }
3319
3320
        // Process toggles (lock/unlock/visibility/rating/notify)
3321
        $lockLabel = '';
3322
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
3323
            $discLocked = (self::check_addlock_discuss($page) === 1);
3324
            $lockLabel  = $discLocked
3325
                ? Display::getMdiIcon(ActionIcon::UNLOCK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Unlock'))
3326
                : Display::getMdiIcon(ActionIcon::LOCK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Lock'));
3327
3328
            $visIcon = (self::check_visibility_discuss($page) === 1)
3329
                ? Display::getMdiIcon(ActionIcon::VISIBLE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Hide'))
3330
                : Display::getMdiIcon(ActionIcon::INVISIBLE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Show'));
3331
3332
            $rateIcon = (self::check_ratinglock_discuss($page) === 1)
3333
                ? Display::getMdiIcon(ActionIcon::STAR, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Unlock'))
3334
                : Display::getMdiIcon(ActionIcon::STAR_OUTLINE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Lock'));
3335
3336
            echo '<div class="flex gap-2 justify-end">'.
3337
                '<a href="'.$ctx['baseUrl'].'&action=discuss&actionpage='.( $discLocked ? 'unlockdisc' : 'lockdisc' ).'&title='.api_htmlentities(urlencode($page)).'">'.$lockLabel.'</a>'.
3338
                '<a href="'.$ctx['baseUrl'].'&action=discuss&actionpage='.( self::check_visibility_discuss($page) ? 'hidedisc' : 'showdisc' ).'&title='.api_htmlentities(urlencode($page)).'">'.$visIcon.'</a>'.
3339
                '<a href="'.$ctx['baseUrl'].'&action=discuss&actionpage='.( self::check_ratinglock_discuss($page) ? 'unlockrating' : 'lockrating' ).'&title='.api_htmlentities(urlencode($page)).'">'.$rateIcon.'</a>'.
3340
                '</div>';
3341
        }
3342
3343
        // Notify toggle (course-scope watchers; reuses page-level method)
3344
        $isWatching = (self::check_notify_page($page) === 1);
3345
        $notifyIcon = $isWatching
3346
            ? Display::getMdiIcon(ActionIcon::SEND_SINGLE_EMAIL, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Cancel'))
3347
            : Display::getMdiIcon(ActionIcon::NOTIFY_OFF, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Notify me'));
3348
        echo '<div class="flex gap-2 justify-end">'.
3349
            '<a href="'.$ctx['baseUrl'].'&action=discuss&actionpage='.( $isWatching ? 'unlocknotify' : 'locknotify' ).'&title='.api_htmlentities(urlencode($page)).'">'.$notifyIcon.'</a>'.
3350
            '</div>';
3351
3352
        // Header (title + last editor/time)
3353
        $lastInfo  = $last->getUserId() ? api_get_user_info((int)$last->getUserId()) : false;
3354
        $metaRight = '';
3355
        if ($lastInfo !== false) {
3356
            $metaRight = ' ('.get_lang('The latest version was edited by').' '.UserManager::getUserProfileLink($lastInfo).' '.api_get_local_time($last->getDtime()?->format('Y-m-d H:i:s')).')';
3357
        }
3358
3359
        $assignIcon = '';
3360
        if ((int)$last->getAssignment() === 1) {
3361
            $assignIcon = Display::getMdiIcon(ActionIcon::WIKI_ASSIGNMENT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Assignment desc'));
3362
        } elseif ((int)$last->getAssignment() === 2) {
3363
            $assignIcon = Display::getMdiIcon(ActionIcon::WIKI_WORK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Assignment work'));
3364
        }
3365
3366
        echo '<div id="wikititle">'.$assignIcon.'&nbsp;&nbsp;&nbsp;'.api_htmlentities($last->getTitle()).$metaRight.'</div>';
3367
3368
        // Comment form (only if not locked or user is teacher/admin)
3369
        $discLocked = ((int)$last->getAddlockDisc() === 1);
3370
        $canPost = !$discLocked || api_is_allowed_to_edit(false, true) || api_is_platform_admin();
3371
3372
        if ($canPost) {
3373
            $ratingAllowed = ((int)$last->getRatinglockDisc() === 1) || api_is_allowed_to_edit(false, true) || api_is_platform_admin();
3374
            $ratingSelect = $ratingAllowed
3375
                ? '<select name="rating" id="rating" class="form-control">'.
3376
                '<option value="-" selected>-</option>'.
3377
                implode('', array_map(static fn($n) => '<option value="'.$n.'">'.$n.'</option>', range(0,10))).
3378
                '</select>'
3379
                : '<input type="hidden" name="rating" value="-">';
3380
3381
            $actionUrl = $ctx['baseUrl'].'&action=discuss&title='.api_htmlentities(urlencode($page));
3382
            echo '<div class="panel panel-default"><div class="panel-body">'.
3383
                '<form method="post" action="'.$actionUrl.'" class="form-horizontal">'.
3384
                '<input type="hidden" name="wpost_id" value="'.api_get_unique_id().'">'.
3385
                '<div class="form-group">'.
3386
                '<label class="col-sm-2 control-label">'.get_lang('Comments').':</label>'.
3387
                '<div class="col-sm-10"><textarea class="form-control" name="comment" cols="80" rows="5" id="comment"></textarea></div>'.
3388
                '</div>'.
3389
                '<div class="form-group">'.
3390
                '<label class="col-sm-2 control-label">'.get_lang('Rating').':</label>'.
3391
                '<div class="col-sm-10">'.$ratingSelect.'</div>'.
3392
                '</div>'.
3393
                '<div class="form-group">'.
3394
                '<div class="col-sm-offset-2 col-sm-10">'.
3395
                '<button class="btn btn--primary" type="submit" name="Submit">'.get_lang('Send').'</button>'.
3396
                '</div>'.
3397
                '</div>'.
3398
                '</form>'.
3399
                '</div></div>';
3400
        }
3401
3402
        // Handle POST (add comment)
3403
        if (isset($_POST['Submit']) && self::double_post($_POST['wpost_id'] ?? '')) {
3404
            $comment = (string)($_POST['comment'] ?? '');
3405
            $scoreIn = (string)($_POST['rating'] ?? '-');
3406
            $score   = $scoreIn !== '-' ? max(0, min(10, (int)$scoreIn)) : null;
3407
3408
            $disc = new CWikiDiscuss();
3409
            $disc
3410
                ->setCId($ctx['courseId'])
3411
                ->setPublicationId((int)$last->getPageId())
3412
                ->setUsercId(api_get_user_id())
3413
                ->setComment($comment)
3414
                ->setPScore($scoreIn !== '-' ? $score : null)
3415
                ->setDtime(api_get_utc_datetime(null, false, true));
3416
3417
            $em->persist($disc);
3418
            $em->flush();
3419
3420
            self::check_emailcue((int)$last->getIid(), 'D', api_get_utc_datetime(), api_get_user_id());
3421
3422
            header('Location: '.$ctx['baseUrl'].'&action=discuss&title='.api_htmlentities(urlencode($page)));
3423
            exit;
3424
        }
3425
3426
        echo '<hr noshade size="1">';
3427
3428
        // Load comments
3429
        $discRepo = $em->getRepository(CWikiDiscuss::class);
3430
        $reviews  = $discRepo->createQueryBuilder('d')
3431
            ->andWhere('d.cId = :cid')->setParameter('cid', $ctx['courseId'])
3432
            ->andWhere('d.publicationId = :pid')->setParameter('pid', (int)$last->getPageId())
3433
            ->orderBy('d.iid', 'DESC')
3434
            ->getQuery()->getResult();
3435
3436
        $countAll   = count($reviews);
3437
        $scored     = array_values(array_filter($reviews, static fn($r) => $r->getPScore() !== null));
3438
        $countScore = count($scored);
3439
        $avg        = $countScore > 0 ? round(array_sum(array_map(static fn($r) => (int)$r->getPScore(), $scored)) / $countScore, 2) : 0.0;
3440
3441
        echo get_lang('Num comments').': '.$countAll.' - '.get_lang('Num comments score').': '.$countScore.' - '.get_lang('Rating media').': '.$avg;
3442
3443
        // Persist average into wiki.score (fits integer nullable; we save rounded int)
3444
        $last->setScore((int)round($avg));
3445
        $em->flush();
3446
3447
        echo '<hr noshade size="1">';
3448
3449
        foreach ($reviews as $r) {
3450
            $ui = api_get_user_info((int)$r->getUsercId());
3451
            $role = ($ui && (string)$ui['status'] === '5') ? get_lang('Student') : get_lang('Teacher');
3452
            $name = $ui ? $ui['complete_name'] : get_lang('Anonymous');
3453
            $avatar = $ui && !empty($ui['avatar']) ? $ui['avatar'] : UserManager::getUserPicture((int)$r->getUsercId());
3454
            $profile = $ui ? UserManager::getUserProfileLink($ui) : api_htmlentities($name);
3455
3456
            $score = $r->getPScore();
3457
            $scoreText = ($score === null) ? '-' : (string)$score;
3458
3459
            echo '<p><table>'.
3460
                '<tr>'.
3461
                '<td rowspan="2"><img src="'.api_htmlentities($avatar).'" alt="'.api_htmlentities($name).'" width="40" height="50" /></td>'.
3462
                '<td style="color:#999">'.$profile.' ('.$role.') '.api_get_local_time($r->getDtime()?->format('Y-m-d H:i:s')).' - '.get_lang('Rating').': '.$scoreText.'</td>'.
3463
                '</tr>'.
3464
                '<tr>'.
3465
                '<td>'.api_htmlentities((string)$r->getComment()).'</td>'.
3466
                '</tr>'.
3467
                '</table></p>';
3468
        }
3469
    }
3470
3471
    public static function check_addlock_discuss(string $page, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): int
3472
    {
3473
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
3474
        $em   = Container::getEntityManager();
3475
        $repo = self::repo();
3476
3477
        /** @var CWiki|null $row */
3478
        $row = $repo->createQueryBuilder('w')
3479
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3480
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
3481
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3482
            ->orderBy('w.version', 'ASC')
3483
            ->setMaxResults(1)
3484
            ->getQuery()->getOneOrNullResult();
3485
3486
        if (!$row) { return 0; }
3487
3488
        $status = (int)$row->getAddlockDisc();
3489
        $pid    = (int)$row->getPageId();
3490
3491
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
3492
            $act = (string)($_GET['actionpage'] ?? '');
3493
            if ($act === 'lockdisc' && $status === 0) { $status = 1; }
3494
            if ($act === 'unlockdisc' && $status === 1) { $status = 0; }
3495
3496
            $em->createQuery('UPDATE '.CWiki::class.' w SET w.addlockDisc = :v WHERE w.cId = :cid AND w.pageId = :pid')
3497
                ->setParameter('v', $status)
3498
                ->setParameter('cid', $ctx['courseId'])
3499
                ->setParameter('pid', $pid)
3500
                ->execute();
3501
3502
            $row = $repo->createQueryBuilder('w')
3503
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3504
                ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
3505
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3506
                ->orderBy('w.version', 'ASC')
3507
                ->setMaxResults(1)
3508
                ->getQuery()->getOneOrNullResult();
3509
        }
3510
3511
        return (int)($row?->getAddlockDisc() ?? 0);
3512
    }
3513
3514
    public static function check_visibility_discuss(string $page, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): int
3515
    {
3516
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
3517
        $em   = Container::getEntityManager();
3518
        $repo = self::repo();
3519
3520
        /** @var CWiki|null $row */
3521
        $row = $repo->createQueryBuilder('w')
3522
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3523
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
3524
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3525
            ->orderBy('w.version', 'ASC')
3526
            ->setMaxResults(1)
3527
            ->getQuery()->getOneOrNullResult();
3528
3529
        if (!$row) { return 0; }
3530
3531
        $status = (int)$row->getVisibilityDisc();
3532
        $pid    = (int)$row->getPageId();
3533
3534
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
3535
            $act = (string)($_GET['actionpage'] ?? '');
3536
            if ($act === 'showdisc' && $status === 0) { $status = 1; }
3537
            if ($act === 'hidedisc' && $status === 1) { $status = 0; }
3538
3539
            $em->createQuery('UPDATE '.CWiki::class.' w SET w.visibilityDisc = :v WHERE w.cId = :cid AND w.pageId = :pid')
3540
                ->setParameter('v', $status)
3541
                ->setParameter('cid', $ctx['courseId'])
3542
                ->setParameter('pid', $pid)
3543
                ->execute();
3544
3545
            $row = $repo->createQueryBuilder('w')
3546
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3547
                ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
3548
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3549
                ->orderBy('w.version', 'ASC')
3550
                ->setMaxResults(1)
3551
                ->getQuery()->getOneOrNullResult();
3552
        }
3553
3554
        return (int)($row?->getVisibilityDisc() ?? 1);
3555
    }
3556
3557
    public static function check_ratinglock_discuss(string $page, ?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): int
3558
    {
3559
        $ctx  = self::ctx($courseId, $sessionId, $groupId);
3560
        $em   = Container::getEntityManager();
3561
        $repo = self::repo();
3562
3563
        /** @var CWiki|null $row */
3564
        $row = $repo->createQueryBuilder('w')
3565
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3566
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
3567
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3568
            ->orderBy('w.version', 'ASC')
3569
            ->setMaxResults(1)
3570
            ->getQuery()->getOneOrNullResult();
3571
3572
        if (!$row) { return 0; }
3573
3574
        $status = (int)$row->getRatinglockDisc();
3575
        $pid    = (int)$row->getPageId();
3576
3577
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
3578
            $act = (string)($_GET['actionpage'] ?? '');
3579
            if ($act === 'lockrating' && $status === 1) { $status = 0; }
3580
            if ($act === 'unlockrating' && $status === 0) { $status = 1; }
3581
3582
            $em->createQuery('UPDATE '.CWiki::class.' w SET w.ratinglockDisc = :v WHERE w.cId = :cid AND w.pageId = :pid')
3583
                ->setParameter('v', $status)
3584
                ->setParameter('cid', $ctx['courseId'])
3585
                ->setParameter('pid', $pid)
3586
                ->execute();
3587
3588
            $row = $repo->createQueryBuilder('w')
3589
                ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3590
                ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($page))
3591
                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3592
                ->orderBy('w.version', 'ASC')
3593
                ->setMaxResults(1)
3594
                ->getQuery()->getOneOrNullResult();
3595
        }
3596
3597
        return (int)($row?->getRatinglockDisc() ?? 1);
3598
    }
3599
3600
    public function deletePageWarning(): void
3601
    {
3602
        $ctx  = self::ctx();
3603
        $repo = self::repo();
3604
        $em   = Container::getEntityManager();
3605
3606
        // Permissions
3607
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
3608
            Display::addFlash(Display::return_message(get_lang('OnlyAdminDeletePageWiki'), 'normal', false));
3609
            return;
3610
        }
3611
3612
        // Page to delete
3613
        $pageRaw = $_GET['title'] ?? '';
3614
        $page    = self::normalizeReflink($pageRaw);
3615
        if ($page === '') {
3616
            Display::addFlash(Display::return_message(get_lang('MustSelectPage'), 'error', false));
3617
            header('Location: '.$ctx['baseUrl'].'&action=allpages');
3618
            exit;
3619
        }
3620
3621
        // Resolve first version (to get page_id) within this context
3622
        $qbFirst = $repo->createQueryBuilder('w')
3623
            ->andWhere('w.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
3624
            ->andWhere('w.reflink = :r')->setParameter('r', $page)
3625
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3626
            ->orderBy('w.version', 'ASC')
3627
            ->setMaxResults(1);
3628
3629
        if ((int)$ctx['sessionId'] > 0) {
3630
            $qbFirst->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
3631
        } else {
3632
            $qbFirst->andWhere('COALESCE(w.sessionId,0) = 0');
3633
        }
3634
3635
        /** @var CWiki|null $first */
3636
        $first = $qbFirst->getQuery()->getOneOrNullResult();
3637
        if (!$first || !(int)$first->getPageId()) {
3638
            Display::addFlash(Display::return_message(get_lang('WikiPageNotFound'), 'error', false));
3639
            header('Location: '.$ctx['baseUrl'].'&action=allpages');
3640
            exit;
3641
        }
3642
3643
        $niceName = self::displayTitleFor($page, $first->getTitle());
3644
3645
        // Warn if deleting the main (index) page
3646
        if ($page === 'index') {
3647
            Display::addFlash(Display::return_message(get_lang('WarningDeleteMainPage'), 'warning', false));
3648
        }
3649
3650
        // Confirmation?
3651
        $confirmed =
3652
            (isset($_POST['confirm_delete']) && $_POST['confirm_delete'] === '1') ||
3653
            (isset($_GET['delete']) && ($_GET['delete'] === 'yes' || $_GET['delete'] === '1'));
3654
3655
        if ($confirmed) {
3656
            // Delete by reflink inside current context
3657
            $ok = $this->deletePageByReflink($page, (int)$ctx['courseId'], (int)$ctx['sessionId'], (int)$ctx['groupId']);
3658
3659
            if ($ok) {
3660
                Display::addFlash(
3661
                    Display::return_message(get_lang('WikiPageDeleted'), 'confirmation', false)
3662
                );
3663
            } else {
3664
                Display::addFlash(
3665
                    Display::return_message(get_lang('DeleteFailed'), 'error', false)
3666
                );
3667
            }
3668
3669
            header('Location: '.$ctx['baseUrl'].'&action=allpages');
3670
            exit;
3671
        }
3672
3673
        $postUrl = $this->url(['action' => 'delete', 'title' => $page]);
3674
3675
        $msg  = '<p>'.sprintf(get_lang('Are you sure you want to delete this page and its history?'), '<b>'.api_htmlentities($niceName).'</b>').'</p>';
3676
        $msg .= '<form method="post" action="'.Security::remove_XSS($postUrl).'" style="display:inline-block;margin-right:1rem;">';
3677
        $msg .= '<input type="hidden" name="confirm_delete" value="1">';
3678
        $msg .= '<button type="submit" class="btn btn-danger">'.get_lang('Yes').'</button>';
3679
        $msg .= '&nbsp;&nbsp;<a class="btn btn-default" href="'.$ctx['baseUrl'].'&action=allpages">'.get_lang('No').'</a>';
3680
        $msg .= '</form>';
3681
3682
        echo Display::return_message($msg, 'warning', false);
3683
    }
3684
3685
    private function deletePageByReflink(
3686
        string $reflink,
3687
        ?int $courseId = null,
3688
        ?int $sessionId = null,
3689
        ?int $groupId = null
3690
    ): bool {
3691
        $ctx = self::ctx($courseId, $sessionId, $groupId);
3692
        $em  = Container::getEntityManager();
3693
        $repo = self::repo();
3694
3695
        /** @var CWiki|null $first */
3696
        $first = $repo->createQueryBuilder('w')
3697
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
3698
            ->andWhere('w.reflink = :r')->setParameter('r', html_entity_decode($reflink))
3699
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
3700
            ->orderBy('w.version', 'ASC')
3701
            ->setMaxResults(1)
3702
            ->getQuery()->getOneOrNullResult();
3703
3704
        if (!$first) {
3705
            return false;
3706
        }
3707
3708
        $pageId = (int)$first->getPageId();
3709
3710
        // Delete Conf for this pageId
3711
        $em->createQuery('DELETE FROM '.CWikiConf::class.' c WHERE c.cId = :cid AND c.pageId = :pid')
3712
            ->setParameter('cid', $ctx['courseId'])
3713
            ->setParameter('pid', $pageId)
3714
            ->execute();
3715
3716
        // Delete Discuss for this pageId
3717
        $em->createQuery('DELETE FROM '.CWikiDiscuss::class.' d WHERE d.cId = :cid AND d.publicationId = :pid')
3718
            ->setParameter('cid', $ctx['courseId'])
3719
            ->setParameter('pid', $pageId)
3720
            ->execute();
3721
3722
        // Delete all versions (respect group/session)
3723
        $qb = $em->createQuery('DELETE FROM '.CWiki::class.' w WHERE w.cId = :cid AND w.pageId = :pid AND COALESCE(w.groupId,0) = :gid AND '.(
3724
            $ctx['sessionId'] > 0 ? '(COALESCE(w.sessionId,0) = 0 OR w.sessionId = :sid)' : 'COALESCE(w.sessionId,0) = 0'
3725
            ));
3726
        $qb->setParameter('cid', $ctx['courseId'])
3727
            ->setParameter('pid', $pageId)
3728
            ->setParameter('gid', (int)$ctx['groupId']);
3729
        if ($ctx['sessionId'] > 0) { $qb->setParameter('sid', (int)$ctx['sessionId']); }
3730
        $qb->execute();
3731
3732
        self::check_emailcue(0, 'E');
3733
3734
        return true;
3735
    }
3736
3737
    public function allPages(string $action): void
3738
    {
3739
        $ctx = self::ctx(); // ['courseId','groupId','sessionId','baseUrl','courseCode']
3740
        $em  = Container::getEntityManager();
3741
3742
        // Header + "Delete whole wiki" (only teachers/admin)
3743
        echo '<div class="actions">'.get_lang('All pages');
3744
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
3745
            echo ' <a href="'.$ctx['baseUrl'].'&action=deletewiki">'.
3746
                Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Delete wiki')).
3747
                '</a>';
3748
        }
3749
        echo '</div>';
3750
3751
        // Latest version per page (by reflink) in current context
3752
        $qb = $em->createQueryBuilder()
3753
            ->select('w')
3754
            ->from(CWiki::class, 'w')
3755
            ->andWhere('w.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
3756
            ->andWhere('COALESCE(w.groupId, 0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
3757
3758
        if ((int)$ctx['sessionId'] > 0) {
3759
            $qb->andWhere('COALESCE(w.sessionId, 0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
3760
        } else {
3761
            $qb->andWhere('COALESCE(w.sessionId, 0) = 0');
3762
        }
3763
3764
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
3765
            $qb->andWhere('w.visibility = 1');
3766
        }
3767
3768
        // Subquery: max version for each reflink
3769
        $sub = $em->createQueryBuilder()
3770
            ->select('MAX(w2.version)')
3771
            ->from(CWiki::class, 'w2')
3772
            ->andWhere('w2.cId = :cid')
3773
            ->andWhere('w2.reflink = w.reflink')
3774
            ->andWhere('COALESCE(w2.groupId, 0) = :gid');
3775
3776
        if ((int)$ctx['sessionId'] > 0) {
3777
            $sub->andWhere('COALESCE(w2.sessionId, 0) = :sid');
3778
        } else {
3779
            $sub->andWhere('COALESCE(w2.sessionId, 0) = 0');
3780
        }
3781
3782
        $qb->andWhere('w.version = ('.$sub->getDQL().')')
3783
            ->orderBy('w.title', 'ASC');
3784
3785
        /** @var CWiki[] $pages */
3786
        $pages = $qb->getQuery()->getResult();
3787
3788
        // Prefetch Conf->task (avoid N+1)
3789
        $pageIds = array_values(array_unique(array_filter(
3790
            array_map(static fn(CWiki $w) => $w->getPageId(), $pages),
3791
            static fn($v) => $v !== null
3792
        )));
3793
        $taskByPageId = [];
3794
        if ($pageIds) {
3795
            $confQb = self::confRepo()->createQueryBuilder('c');
3796
            $confs = $confQb
3797
                ->select('c.pageId, c.task')
3798
                ->andWhere('c.cId = :cid')->setParameter('cid', (int)$ctx['courseId'])
3799
                ->andWhere($confQb->expr()->in('c.pageId', ':pids'))->setParameter('pids', $pageIds)
3800
                ->getQuery()->getArrayResult();
3801
3802
            foreach ($confs as $c) {
3803
                if (!empty($c['task'])) {
3804
                    $taskByPageId[(int)$c['pageId']] = true;
3805
                }
3806
            }
3807
        }
3808
3809
        // Build rows: ALWAYS strings so TableSort can safely run strip_tags()
3810
        $rows = [];
3811
        foreach ($pages as $w) {
3812
            $hasTask = !empty($taskByPageId[(int)$w->getPageId()]);
3813
            $titlePack = json_encode([
3814
                'title'   => (string) $w->getTitle(),
3815
                'reflink' => (string) $w->getReflink(),
3816
                'iid'     => (int) $w->getIid(),
3817
                'hasTask' => (bool) $hasTask,
3818
            ], JSON_UNESCAPED_UNICODE);
3819
3820
            $authorPack = json_encode([
3821
                'userId' => (int) $w->getUserId(),
3822
                'ip'     => (string) $w->getUserIp(),
3823
            ], JSON_UNESCAPED_UNICODE);
3824
3825
            $rows[] = [
3826
                (string) $w->getAssignment(),                                       // 0: type (iconified)
3827
                $titlePack,                                                         // 1: title data (JSON string)
3828
                $authorPack,                                                        // 2: author data (JSON string)
3829
                $w->getDtime() ? $w->getDtime()->format('Y-m-d H:i:s') : '',        // 3: date string
3830
                (string) $w->getReflink(),                                          // 4: actions (needs reflink)
3831
            ];
3832
        }
3833
3834
        $table = new SortableTableFromArrayConfig(
3835
            $rows,
3836
            1,
3837
            25,
3838
            'AllPages_table',
3839
            '',
3840
            '',
3841
            'ASC'
3842
        );
3843
3844
        $table->set_additional_parameters([
3845
            'cid'     => $ctx['courseId'],
3846
            'gid'     => $ctx['groupId'],
3847
            'sid' => $ctx['sessionId'],
3848
            'action'     => Security::remove_XSS($action),
3849
        ]);
3850
3851
        $table->set_header(0, get_lang('Type'), true, ['style' => 'width:48px;']);
3852
        $table->set_header(1, get_lang('Title'), true);
3853
        $table->set_header(2, get_lang('Author').' <small>'.get_lang('Last version').'</small>');
3854
        $table->set_header(3, get_lang('Date').' <small>'.get_lang('Last version').'</small>');
3855
3856
        if (api_is_allowed_to_session_edit(false, true)) {
3857
            $table->set_header(4, get_lang('Actions'), false, ['style' => 'width: 280px;']);
3858
        }
3859
3860
        // Column 0: icons (type + task badge)
3861
        $table->set_column_filter(0, function ($value, string $urlParams, array $row) {
3862
            $icons = self::assignmentIcon((int)$value);
3863
            $packed = json_decode((string)$row[1], true) ?: [];
3864
            if (!empty($packed['hasTask'])) {
3865
                $icons .= Display::getMdiIcon(
3866
                    ActionIcon::WIKI_TASK,
3867
                    'ch-tool-icon',
3868
                    null,
3869
                    ICON_SIZE_SMALL,
3870
                    get_lang('Standard task')
3871
                );
3872
            }
3873
            return $icons;
3874
        });
3875
3876
        // Column 1: title link + categories
3877
        $table->set_column_filter(1, function ($value) use ($ctx) {
3878
            $data = json_decode((string)$value, true) ?: [];
3879
            $ref  = (string)($data['reflink'] ?? '');
3880
            $rawTitle = (string)($data['title'] ?? '');
3881
            $iid  = (int)($data['iid'] ?? 0);
3882
3883
            // Show "Home" for index if DB title is empty
3884
            $display = self::displayTitleFor($ref, $rawTitle);
3885
3886
            $href = $ctx['baseUrl'].'&'.http_build_query([
3887
                    'action' => 'showpage',
3888
                    'title'  => api_htmlentities($ref),
3889
                ]);
3890
3891
            return Display::url(api_htmlentities($display), $href)
3892
                . self::returnCategoriesBlock($iid, '<div><small>', '</small></div>');
3893
        });
3894
3895
        // Column 2: author
3896
        $table->set_column_filter(2, function ($value) {
3897
            $data = json_decode((string)$value, true) ?: [];
3898
            $uid  = (int)($data['userId'] ?? 0);
3899
            $ip   = (string)($data['ip'] ?? '');
3900
            return self::authorLink($uid, $ip);
3901
        });
3902
3903
        // Column 3: local time
3904
        $table->set_column_filter(3, function ($value) {
3905
            return !empty($value) ? api_get_local_time($value) : '';
3906
        });
3907
3908
        // Column 4: actions
3909
        $table->set_column_filter(4, function ($value) use ($ctx) {
3910
            if (!api_is_allowed_to_session_edit(false, true)) {
3911
                return '';
3912
            }
3913
            $ref = (string)$value;
3914
3915
            $actions  = Display::url(
3916
                Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('EditPage')),
3917
                $ctx['baseUrl'].'&'.http_build_query(['action' => 'edit', 'title' => api_htmlentities($ref)])
3918
            );
3919
            $actions .= Display::url(
3920
                Display::getMdiIcon(ActionIcon::COMMENT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Discuss')),
3921
                $ctx['baseUrl'].'&'.http_build_query(['action' => 'discuss', 'title' => api_htmlentities($ref)])
3922
            );
3923
            $actions .= Display::url(
3924
                Display::getMdiIcon(ActionIcon::HISTORY, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('History')),
3925
                $ctx['baseUrl'].'&'.http_build_query(['action' => 'history', 'title' => api_htmlentities($ref)])
3926
            );
3927
            $actions .= Display::url(
3928
                Display::getMdiIcon(ActionIcon::LINKS, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('LinksPages')),
3929
                $ctx['baseUrl'].'&'.http_build_query(['action' => 'links', 'title' => api_htmlentities($ref)])
3930
            );
3931
3932
            if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
3933
                $actions .= Display::url(
3934
                    Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Delete')),
3935
                    $ctx['baseUrl'].'&'.http_build_query(['action' => 'delete', 'title' => api_htmlentities($ref)])
3936
                );
3937
            }
3938
            return $actions;
3939
        });
3940
3941
        $table->display();
3942
    }
3943
3944
    public function getSearchPages(string $action): void
3945
    {
3946
        $ctx = self::ctx();
3947
        $url = $ctx['baseUrl'].'&'.http_build_query(['action' => api_htmlentities($action), 'mode_table' => 'yes1']);
3948
3949
        echo '<div class="actions">'.get_lang('Search').'</div>';
3950
3951
        if (isset($_GET['mode_table'])) {
3952
            if (!isset($_GET['SearchPages_table_page_nr'])) {
3953
                $_GET['search_term']         = $_POST['search_term'] ?? '';
3954
                $_GET['search_content']      = $_POST['search_content'] ?? '';
3955
                $_GET['all_vers']            = $_POST['all_vers'] ?? '';
3956
                $_GET['categories']          = $_POST['categories'] ?? [];
3957
                $_GET['match_all_categories']= !empty($_POST['match_all_categories']);
3958
            }
3959
            $this->display_wiki_search_results(
3960
                (string) $_GET['search_term'],
3961
                (int)    $_GET['search_content'],
3962
                (int)    $_GET['all_vers'],
3963
                (array)  $_GET['categories'],
3964
                (bool)   $_GET['match_all_categories']
3965
            );
3966
            return;
3967
        }
3968
3969
        // Build form
3970
        $form = new FormValidator('wiki_search', 'get', $url);
3971
        $form->addHidden('cid',      $ctx['courseId']);
3972
        $form->addHidden('sid',  $ctx['sessionId']);
3973
        $form->addHidden('gid',      $ctx['groupId']);
3974
        $form->addHidden('gradebook',   '0');
3975
        $form->addHidden('origin',      '');
3976
        $form->addHidden('action',      'searchpages');
3977
3978
        $form->addText('search_term', get_lang('Search term'), false, ['autofocus' => 'autofocus']);
3979
        $form->addCheckBox('search_content', '', get_lang('Search also in content'));
3980
        $form->addCheckbox('all_vers', '', get_lang('Also search in older versions of each page'));
3981
3982
        if (self::categoriesEnabled()) {
3983
            $categories = Container::getEntityManager()
3984
                ->getRepository(CWikiCategory::class)
3985
                ->findByCourse(api_get_course_entity());
3986
            $form->addSelectFromCollection(
3987
                'categories',
3988
                get_lang('Categories'),
3989
                $categories,
3990
                ['multiple' => 'multiple'],
3991
                false,
3992
                'getNodeName'
3993
            );
3994
            $form->addCheckBox('match_all_categories', '', get_lang('Must be in ALL the selected categories'));
3995
        }
3996
3997
        $form->addButtonSearch(get_lang('Search'), 'SubmitWikiSearch');
3998
        $form->addRule('search_term', get_lang('Too short'), 'minlength', 3);
3999
4000
        if ($form->validate()) {
4001
            $form->display();
4002
            $values = $form->exportValues();
4003
            $this->display_wiki_search_results(
4004
                (string)$values['search_term'],
4005
                (int)($values['search_content'] ?? 0),
4006
                (int)($values['all_vers'] ?? 0),
4007
                (array)($values['categories'] ?? []),
4008
                !empty($values['match_all_categories'])
4009
            );
4010
        } else {
4011
            $form->display();
4012
        }
4013
    }
4014
4015
    public function display_wiki_search_results(
4016
        string $searchTerm,
4017
        int $searchContent = 0,
4018
        int $allVersions = 0,
4019
        array $categoryIdList = [],
4020
        bool $matchAllCategories = false
4021
    ): void {
4022
        $ctx  = self::ctx();
4023
        $em   = Container::getEntityManager();
4024
        $repo = self::repo();
4025
        $url  = $ctx['baseUrl'];
4026
4027
        $categoryIdList = array_map('intval', $categoryIdList);
4028
4029
        echo '<legend>'.get_lang('Wiki search results').': '.Security::remove_XSS($searchTerm).'</legend>';
4030
4031
        $qb = $repo->createQueryBuilder('wp');
4032
        $qb->andWhere('wp.cId = :cid')->setParameter('cid', $ctx['courseId'])
4033
            ->andWhere('COALESCE(wp.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4034
4035
        if ($ctx['sessionId'] > 0) {
4036
            $qb->andWhere('COALESCE(wp.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4037
        } else {
4038
            $qb->andWhere('COALESCE(wp.sessionId,0) = 0');
4039
        }
4040
4041
        // Visibility for students
4042
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
4043
            $qb->andWhere('wp.visibility = 1');
4044
        }
4045
4046
        // Search by title (+content if requested)
4047
        $likeTerm = '%'.$searchTerm.'%';
4048
        $or = $qb->expr()->orX(
4049
            $qb->expr()->like('wp.title', ':term')
4050
        );
4051
        if ($searchContent === 1) {
4052
            $or->add($qb->expr()->like('wp.content', ':term'));
4053
        }
4054
        $qb->andWhere($or)->setParameter('term', $likeTerm);
4055
4056
        // Categories filter
4057
        if (!empty($categoryIdList)) {
4058
            if ($matchAllCategories) {
4059
                $i = 0;
4060
                foreach ($categoryIdList as $catId) {
4061
                    ++$i;
4062
                    $aliasC = 'wc'.$i;
4063
                    $qb->innerJoin('wp.categories', $aliasC, 'WITH', $qb->expr()->eq($aliasC.'.id', ':cid'.$i))
4064
                        ->setParameter('cid'.$i, $catId);
4065
                }
4066
            } else {
4067
                $qb->innerJoin('wp.categories', 'wc')
4068
                    ->andWhere('wc.id IN (:cids)')
4069
                    ->setParameter('cids', $categoryIdList);
4070
            }
4071
        }
4072
4073
        // Only latest per page unless allVersions=1
4074
        if ($allVersions !== 1) {
4075
            $sub = $em->createQueryBuilder()
4076
                ->select('MAX(s2.version)')
4077
                ->from(\Chamilo\CourseBundle\Entity\CWiki::class, 's2')
4078
                ->andWhere('s2.cId = :cid')
4079
                ->andWhere('s2.reflink = wp.reflink')
4080
                ->andWhere('COALESCE(s2.groupId,0) = :gid');
4081
4082
            if ($ctx['sessionId'] > 0) {
4083
                $sub->andWhere('COALESCE(s2.sessionId,0) = :sid');
4084
            } else {
4085
                $sub->andWhere('COALESCE(s2.sessionId,0) = 0');
4086
            }
4087
            $qb->andWhere($qb->expr()->eq('wp.version', '(' . $sub->getDQL() . ')'));
4088
        }
4089
4090
        $qb->orderBy('wp.dtime', 'DESC');
4091
4092
        /** @var \Chamilo\CourseBundle\Entity\CWiki[] $rows */
4093
        $rows = $qb->getQuery()->getResult();
4094
4095
        if (!$rows) {
4096
            echo get_lang('NoSearchResults');
4097
            return;
4098
        }
4099
4100
        // Icons
4101
        $iconEdit    = Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('EditPage'));
4102
        $iconDiscuss = Display::getMdiIcon(ActionIcon::COMMENT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Discuss'));
4103
        $iconHistory = Display::getMdiIcon(ActionIcon::HISTORY, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('History'));
4104
        $iconLinks   = Display::getMdiIcon(ActionIcon::LINKS, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('LinksPages'));
4105
        $iconDelete  = Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Delete'));
4106
4107
        $data = [];
4108
        foreach ($rows as $w) {
4109
            $assignIcon = self::assignmentIcon((int)$w->getAssignment());
4110
4111
            $wikiLinkParams = ['action' => 'showpage', 'title' => $w->getReflink()];
4112
            if ($allVersions === 1) {
4113
                $wikiLinkParams['view'] = $w->getIid();
4114
            }
4115
4116
            $titleLink = Display::url(
4117
                    api_htmlentities($w->getTitle()),
4118
                    $url.'&'.http_build_query($wikiLinkParams)
4119
                ).self::returnCategoriesBlock((int)$w->getIid(), '<div><small>', '</small></div>');
4120
4121
            $author = self::authorLink((int)$w->getUserId(), (string)$w->getUserIp());
4122
            $date   = api_convert_and_format_date($w->getDtime());
4123
4124
            if ($allVersions === 1) {
4125
                $data[] = [$assignIcon, $titleLink, $author, $date, (int)$w->getVersion()];
4126
            } else {
4127
                $actions  = '';
4128
                $actions .= Display::url($iconEdit,    $url.'&'.http_build_query(['action' => 'edit',    'title' => $w->getReflink()]));
4129
                $actions .= Display::url($iconDiscuss, $url.'&'.http_build_query(['action' => 'discuss', 'title' => $w->getReflink()]));
4130
                $actions .= Display::url($iconHistory, $url.'&'.http_build_query(['action' => 'history', 'title' => $w->getReflink()]));
4131
                $actions .= Display::url($iconLinks,   $url.'&'.http_build_query(['action' => 'links',   'title' => $w->getReflink()]));
4132
                if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
4133
                    $actions .= Display::url($iconDelete, $url.'&'.http_build_query(['action' => 'delete', 'title' => $w->getReflink()]));
4134
                }
4135
4136
                $data[] = [$assignIcon, $titleLink, $author, $date, $actions];
4137
            }
4138
        }
4139
4140
        $table = new SortableTableFromArrayConfig(
4141
            $data,
4142
            1,   // default sort by title
4143
            10,
4144
            'SearchPages_table',
4145
            '',
4146
            '',
4147
            'ASC'
4148
        );
4149
4150
        $extra = [
4151
            'cid'                  => (int)$ctx['courseId'],
4152
            'gid'                  => (int)$ctx['groupId'],
4153
            'sid'                  => (int)$ctx['sessionId'],
4154
            'action'               => $_GET['action'] ?? 'searchpages',
4155
            'mode_table'           => 'yes2',
4156
            'search_term'          => (string)$searchTerm,
4157
            'search_content'       => (int)$searchContent,
4158
            'all_vers'             => (int)$allVersions,
4159
            'match_all_categories' => $matchAllCategories ? 1 : 0,
4160
        ];
4161
4162
        foreach ($categoryIdList as $i => $cidVal) {
4163
            $extra['categories['.$i.']'] = (int)$cidVal;
4164
        }
4165
        $table->set_additional_parameters($extra);
4166
4167
        $table->set_header(0, get_lang('Type'), true, ['style' => 'width:48px;']);
4168
        $table->set_header(1, get_lang('Title'));
4169
        if ($allVersions === 1) {
4170
            $table->set_header(2, get_lang('Author'));
4171
            $table->set_header(3, get_lang('Date'));
4172
            $table->set_header(4, get_lang('Version'));
4173
        } else {
4174
            $table->set_header(2, get_lang('Author').' <small>'.get_lang('LastVersion').'</small>');
4175
            $table->set_header(3, get_lang('Date').' <small>'.get_lang('LastVersion').'</small>');
4176
            $table->set_header(4, get_lang('Actions'), false, ['style' => 'width:280px;']);
4177
        }
4178
        $table->display();
4179
    }
4180
4181
    public function recentChanges(string $page, string $action): void
4182
    {
4183
        $ctx = self::ctx();
4184
        $url = $ctx['baseUrl'];
4185
4186
        // Top bar: notify-all toggle (only if user can session-edit)
4187
        $notifyBlock = '';
4188
        if (api_is_allowed_to_session_edit(false, true)) {
4189
            if (self::check_notify_all() === 1) {
4190
                $notifyBlock = Display::getMdiIcon(ActionIcon::INFORMATION, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('CancelNotifyByEmail'))
4191
                    .' '.get_lang('Not notify changes');
4192
                $act = 'unlocknotifyall';
4193
            } else {
4194
                $notifyBlock = Display::getMdiIcon(ActionIcon::SEND_SINGLE_EMAIL, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('NotifyByEmail'))
4195
                    .' '.get_lang('Notify changes');
4196
                $act = 'locknotifyall';
4197
            }
4198
4199
            echo '<div class="actions"><span style="float:right;">'.
4200
                '<a href="'.$url.'&action=recentchanges&actionpage='.$act.'&title='.api_htmlentities(urlencode($page)).'">'.$notifyBlock.'</a>'.
4201
                '</span>'.get_lang('Recent changes').'</div>';
4202
        } else {
4203
            echo '<div class="actions">'.get_lang('Recent changes').'</div>';
4204
        }
4205
4206
        $repo = self::repo();
4207
        $em   = Container::getEntityManager();
4208
4209
        $qb = $repo->createQueryBuilder('w')
4210
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4211
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4212
4213
        if ($ctx['sessionId'] > 0) {
4214
            $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4215
        } else {
4216
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
4217
        }
4218
4219
        // Students only see visible pages
4220
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
4221
            $qb->andWhere('w.visibility = 1');
4222
        }
4223
4224
        $qb->orderBy('w.dtime', 'DESC');
4225
4226
        /** @var CWiki[] $list */
4227
        $list = $qb->getQuery()->getResult();
4228
4229
        if (empty($list)) {
4230
            return;
4231
        }
4232
4233
        $rows = [];
4234
        foreach ($list as $w) {
4235
            $assignIcon = self::assignmentIcon((int)$w->getAssignment());
4236
4237
            // Task icon?
4238
            $iconTask = '';
4239
            $conf = self::confRepo()->findOneBy(['cId' => $ctx['courseId'], 'pageId' => (int)$w->getPageId()]);
4240
            if ($conf && $conf->getTask()) {
4241
                $iconTask = Display::getMdiIcon(ActionIcon::WIKI_TASK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('StandardTask'));
4242
            }
4243
4244
            $titleLink = Display::url(
4245
                api_htmlentities($w->getTitle()),
4246
                $url.'&'.http_build_query([
4247
                    'action' => 'showpage',
4248
                    'title'  => api_htmlentities($w->getReflink()),
4249
                    'view'   => (int)$w->getIid(), // jump to that version
4250
                ])
4251
            );
4252
4253
            $actionText = ((int)$w->getVersion() > 1) ? get_lang('EditedBy') : get_lang('AddedBy');
4254
            $authorLink = self::authorLink((int)$w->getUserId(), (string)$w->getUserIp());
4255
4256
            $rows[] = [
4257
                api_get_local_time($w->getDtime()),
4258
                $assignIcon.$iconTask,
4259
                $titleLink,
4260
                $actionText,
4261
                $authorLink,
4262
            ];
4263
        }
4264
4265
        $table = new SortableTableFromArrayConfig(
4266
            $rows,
4267
            0,
4268
            10,
4269
            'RecentPages_table',
4270
            '',
4271
            '',
4272
            'DESC'
4273
        );
4274
        $table->set_additional_parameters([
4275
            'cid'     => $ctx['courseId'],
4276
            'gid'     => $ctx['groupId'],
4277
            'sid' => $ctx['sessionId'],
4278
            'action'     => Security::remove_XSS($action),
4279
        ]);
4280
4281
        $table->set_header(0, get_lang('Date'), true, ['style' => 'width:200px;']);
4282
        $table->set_header(1, get_lang('Type'), true, ['style' => 'width:48px;']);
4283
        $table->set_header(2, get_lang('Title'), true);
4284
        $table->set_header(3, get_lang('Actions'), true, ['style' => 'width:120px;']);
4285
        $table->set_header(4, get_lang('Author'), true);
4286
        $table->display();
4287
    }
4288
4289
    private static function assignmentIcon(int $assignment): string
4290
    {
4291
        return match ($assignment) {
4292
            1       => Display::getMdiIcon(ActionIcon::WIKI_ASSIGNMENT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('AssignmentDesc')),
4293
            2       => Display::getMdiIcon(ActionIcon::WIKI_WORK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('AssignmentWork')),
4294
            default => '',
4295
        };
4296
    }
4297
4298
    private static function authorLink(int $userId, string $userIp): string
4299
    {
4300
        $ui = $userId ? api_get_user_info($userId) : false;
4301
        if ($ui !== false) {
4302
            return UserManager::getUserProfileLink($ui);
4303
        }
4304
        return get_lang('Anonymous').' ('.api_htmlentities($userIp).')';
4305
    }
4306
4307
    /** Course-wide watchers toggle for "Recent Changes". Returns 1 if subscribed, else 0 (and processes GET toggles). */
4308
    public static function check_notify_all(?int $courseId = null, ?int $sessionId = null, ?int $groupId = null): int
4309
    {
4310
        $ctx = self::ctx($courseId, $sessionId, $groupId);
4311
        $em  = Container::getEntityManager();
4312
4313
        $userId   = api_get_user_id();
4314
        $repoMail = $em->getRepository(CWikiMailcue::class);
4315
4316
        /** @var CWikiMailcue|null $existing */
4317
        $existing = $repoMail->findOneBy([
4318
            'cId'       => $ctx['courseId'],
4319
            'groupId'   => (int)$ctx['groupId'],
4320
            'sessionId' => (int)$ctx['sessionId'],
4321
            'userId'    => $userId,
4322
        ]);
4323
4324
        if (api_is_allowed_to_session_edit() && !empty($_GET['actionpage'])) {
4325
            $act = (string) $_GET['actionpage'];
4326
4327
            if ('locknotifyall' === $act && !$existing) {
4328
                $cue = new CWikiMailcue();
4329
                $cue->setCId($ctx['courseId'])
4330
                    ->setUserId($userId)
4331
                    ->setGroupId((int)$ctx['groupId'])
4332
                    ->setSessionId((int)$ctx['sessionId'])
4333
                    ->setType('wiki');
4334
                $em->persist($cue);
4335
                $em->flush();
4336
                $existing = $cue;
4337
            }
4338
4339
            if ('unlocknotifyall' === $act && $existing) {
4340
                $em->remove($existing);
4341
                $em->flush();
4342
                $existing = null;
4343
            }
4344
        }
4345
4346
        return $existing ? 1 : 0;
4347
    }
4348
4349
    public function getUserContributions(int $userId, string $action): void
4350
    {
4351
        $ctx = self::ctx();
4352
        $url = $ctx['baseUrl'];
4353
        $userId = (int) $userId;
4354
4355
        $userinfo = api_get_user_info($userId);
4356
        if ($userinfo !== false) {
4357
            echo '<div class="actions">'.
4358
                Display::url(
4359
                    get_lang('User contributions').': '.$userinfo['complete_name_with_username'],
4360
                    $url.'&'.http_build_query(['action' => 'usercontrib', 'user_id' => $userId])
4361
                ).
4362
                '</div>';
4363
        }
4364
4365
        $qb = self::repo()->createQueryBuilder('w')
4366
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4367
            ->andWhere('w.userId = :uid')->setParameter('uid', $userId)
4368
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4369
4370
        if ($ctx['sessionId'] > 0) {
4371
            $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4372
        } else {
4373
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
4374
        }
4375
4376
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
4377
            $qb->andWhere('w.visibility = 1');
4378
        }
4379
4380
        $qb->orderBy('w.dtime', 'DESC');
4381
4382
        /** @var CWiki[] $list */
4383
        $list = $qb->getQuery()->getResult();
4384
4385
        if (empty($list)) {
4386
            return;
4387
        }
4388
4389
        $rows = [];
4390
        foreach ($list as $w) {
4391
            $rows[] = [
4392
                api_get_local_time($w->getDtime()),
4393
                self::assignmentIcon((int)$w->getAssignment()),
4394
                Display::url(
4395
                    api_htmlentities($w->getTitle()),
4396
                    $url.'&'.http_build_query([
4397
                        'action' => 'showpage',
4398
                        'title'  => api_htmlentities($w->getReflink()),
4399
                        'view'   => (int)$w->getIid(),
4400
                    ])
4401
                ),
4402
                Security::remove_XSS((string)$w->getVersion()),
4403
                Security::remove_XSS((string)$w->getComment()),
4404
                Security::remove_XSS((string)$w->getProgress()).' %',
4405
                Security::remove_XSS((string)$w->getScore()),
4406
            ];
4407
        }
4408
4409
        $table = new SortableTableFromArrayConfig($rows, 2, 10, 'UsersContributions_table', '', '', 'ASC');
4410
        $table->set_additional_parameters([
4411
            'cid'     => $ctx['courseId'],
4412
            'gid'     => $ctx['groupId'],
4413
            'sid' => $ctx['sessionId'],
4414
            'action'     => Security::remove_XSS($action),
4415
            'user_id'    => (int)$userId,
4416
        ]);
4417
        $table->set_header(0, get_lang('Date'),    true, ['style' => 'width:200px;']);
4418
        $table->set_header(1, get_lang('Type'),    true, ['style' => 'width:48px;']);
4419
        $table->set_header(2, get_lang('Title'),   true, ['style' => 'width:200px;']);
4420
        $table->set_header(3, get_lang('Version'), true, ['style' => 'width:60px;']);
4421
        $table->set_header(4, get_lang('Comment'), true, ['style' => 'width:200px;']);
4422
        $table->set_header(5, get_lang('Progress'),true, ['style' => 'width:80px;']);
4423
        $table->set_header(6, get_lang('Rating'),  true, ['style' => 'width:80px;']);
4424
        $table->display();
4425
    }
4426
4427
    public function getMostChangedPages(string $action): void
4428
    {
4429
        $ctx = self::ctx();
4430
        $url = $ctx['baseUrl'];
4431
4432
        echo '<div class="actions">'.get_lang('Most changed pages').'</div>';
4433
4434
        // Aggregate: max(version) per reflink with context gates
4435
        $qb = self::repo()->createQueryBuilder('w')
4436
            ->select('w.reflink AS reflink, MAX(w.version) AS changes')
4437
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4438
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4439
4440
        if ($ctx['sessionId'] > 0) {
4441
            $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4442
        } else {
4443
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
4444
        }
4445
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
4446
            $qb->andWhere('w.visibility = 1');
4447
        }
4448
        $qb->groupBy('w.reflink');
4449
4450
        $raw = $qb->getQuery()->getArrayResult();
4451
        if (empty($raw)) {
4452
            return;
4453
        }
4454
4455
        $rows = [];
4456
        foreach ($raw as $r) {
4457
            $reflink = (string)$r['reflink'];
4458
            // Fetch latest page for title + assignment
4459
            $latest = self::repo()->findOneBy(
4460
                ['cId' => $ctx['courseId'], 'reflink' => $reflink, 'groupId' => (int)$ctx['groupId'], 'sessionId' => (int)$ctx['sessionId']],
4461
                ['version' => 'DESC', 'dtime' => 'DESC']
4462
            ) ?? self::repo()->findOneBy(['cId' => $ctx['courseId'], 'reflink' => $reflink], ['version' => 'DESC']);
4463
4464
            if (!$latest) {
4465
                continue;
4466
            }
4467
4468
            $rows[] = [
4469
                self::assignmentIcon((int)$latest->getAssignment()),
4470
                Display::url(
4471
                    api_htmlentities($latest->getTitle()),
4472
                    $url.'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities($reflink)])
4473
                ),
4474
                (int)$r['changes'],
4475
            ];
4476
        }
4477
4478
        $table = new SortableTableFromArrayConfig($rows, 2, 10, 'MostChangedPages_table', '', '', 'DESC');
4479
        $table->set_additional_parameters([
4480
            'cid'     => $ctx['courseId'],
4481
            'gid'     => $ctx['groupId'],
4482
            'sid' => $ctx['sessionId'],
4483
            'action'     => Security::remove_XSS($action),
4484
        ]);
4485
        $table->set_header(0, get_lang('Type'),   true, ['style' => 'width:48px;']);
4486
        $table->set_header(1, get_lang('Title'),  true);
4487
        $table->set_header(2, get_lang('Changes'),true, ['style' => 'width:100px;']);
4488
        $table->display();
4489
    }
4490
4491
    public function getMostVisited(): void
4492
    {
4493
        $ctx = self::ctx();
4494
        $url = $ctx['baseUrl'];
4495
4496
        echo '<div class="actions">'.get_lang('Most visited pages').'</div>';
4497
4498
        // Aggregate: sum(hits) per reflink
4499
        $qb = self::repo()->createQueryBuilder('w')
4500
            ->select('w.reflink AS reflink, SUM(COALESCE(w.hits,0)) AS totalHits')
4501
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4502
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4503
4504
        if ($ctx['sessionId'] > 0) {
4505
            $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4506
        } else {
4507
            $qb->andWhere('COALESCE(w.sessionId,0) = 0');
4508
        }
4509
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
4510
            $qb->andWhere('w.visibility = 1');
4511
        }
4512
        $qb->groupBy('w.reflink');
4513
4514
        $raw = $qb->getQuery()->getArrayResult();
4515
        if (empty($raw)) {
4516
            return;
4517
        }
4518
4519
        $rows = [];
4520
        foreach ($raw as $r) {
4521
            $reflink = (string)$r['reflink'];
4522
            $latest = self::repo()->findOneBy(
4523
                ['cId' => $ctx['courseId'], 'reflink' => $reflink, 'groupId' => (int)$ctx['groupId'], 'sessionId' => (int)$ctx['sessionId']],
4524
                ['version' => 'DESC', 'dtime' => 'DESC']
4525
            ) ?? self::repo()->findOneBy(['cId' => $ctx['courseId'], 'reflink' => $reflink], ['version' => 'DESC']);
4526
4527
            if (!$latest) {
4528
                continue;
4529
            }
4530
4531
            $rows[] = [
4532
                self::assignmentIcon((int)$latest->getAssignment()),
4533
                Display::url(
4534
                    api_htmlentities($latest->getTitle()),
4535
                    $url.'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities($reflink)])
4536
                ),
4537
                (int)$r['totalHits'],
4538
            ];
4539
        }
4540
4541
        $table = new SortableTableFromArrayConfig($rows, 2, 10, 'MostVisitedPages_table', '', '', 'DESC');
4542
        $table->set_additional_parameters([
4543
            'cid'     => $ctx['courseId'],
4544
            'gid'     => $ctx['groupId'],
4545
            'sid' => $ctx['sessionId'],
4546
            'action'     => Security::remove_XSS($this->action ?? 'mvisited'),
4547
        ]);
4548
        $table->set_header(0, get_lang('Type'),   true, ['style' => 'width:48px;']);
4549
        $table->set_header(1, get_lang('Title'),  true);
4550
        $table->set_header(2, get_lang('Visits'), true, ['style' => 'width:100px;']);
4551
        $table->display();
4552
    }
4553
4554
    public function getMostLinked(): void
4555
    {
4556
        $ctx = self::ctx();
4557
        $url = $ctx['baseUrl'];
4558
4559
        echo '<div class="actions">'.get_lang('Most linked pages').'</div>';
4560
4561
        // All existing page reflinks in context
4562
        $qbPages = self::repo()->createQueryBuilder('w')
4563
            ->select('DISTINCT w.reflink AS reflink')
4564
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4565
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4566
        if ($ctx['sessionId'] > 0) {
4567
            $qbPages->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4568
        } else {
4569
            $qbPages->andWhere('COALESCE(w.sessionId,0) = 0');
4570
        }
4571
        $pages = array_map(fn($r) => (string)$r['reflink'], $qbPages->getQuery()->getArrayResult());
4572
4573
        // Latest version of every page in context
4574
        $latestList = $this->getLatestPagesForContext();
4575
4576
        // Collect "linksto" tokens pointing to existing pages (excluding self)
4577
        $linked = [];
4578
        foreach ($latestList as $w) {
4579
            $selfRef = $w->getReflink();
4580
            $tokens  = preg_split('/\s+/', trim((string)$w->getLinksto())) ?: [];
4581
            foreach ($tokens as $t) {
4582
                $t = trim($t);
4583
                if ($t === '' || $t === $selfRef) {
4584
                    continue;
4585
                }
4586
                if (in_array($t, $pages, true)) {
4587
                    $linked[] = $t;
4588
                }
4589
            }
4590
        }
4591
4592
        $linked = array_values(array_unique($linked));
4593
        $rows = [];
4594
        foreach ($linked as $ref) {
4595
            $rows[] = [
4596
                Display::url(
4597
                    str_replace('_', ' ', $ref),
4598
                    $url.'&'.http_build_query(['action' => 'showpage', 'title' => str_replace('_', ' ', $ref)])
4599
                ),
4600
            ];
4601
        }
4602
4603
        $table = new SortableTableFromArrayConfig($rows, 0, 10, 'LinkedPages_table', '', '', 'ASC');
4604
        $table->set_additional_parameters([
4605
            'cid'     => $ctx['courseId'],
4606
            'gid'     => $ctx['groupId'],
4607
            'sid' => $ctx['sessionId'],
4608
            'action'     => Security::remove_XSS($this->action ?? 'mostlinked'),
4609
        ]);
4610
        $table->set_header(0, get_lang('Title'), true);
4611
        $table->display();
4612
    }
4613
4614
    public function getOrphaned(): void
4615
    {
4616
        $ctx = self::ctx();
4617
        $url = $ctx['baseUrl'];
4618
4619
        echo '<div class="actions">'.get_lang('Orphaned pages').'</div>';
4620
4621
        // All page reflinks in context
4622
        $qbPages = self::repo()->createQueryBuilder('w')
4623
            ->select('DISTINCT w.reflink AS reflink')
4624
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4625
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4626
        if ($ctx['sessionId'] > 0) {
4627
            $qbPages->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4628
        } else {
4629
            $qbPages->andWhere('COALESCE(w.sessionId,0) = 0');
4630
        }
4631
        $pages = array_map(fn($r) => (string)$r['reflink'], $qbPages->getQuery()->getArrayResult());
4632
4633
        // Latest version per reflink
4634
        $latestList = $this->getLatestPagesForContext();
4635
4636
        // Gather all linksto tokens across latest versions
4637
        $linkedTokens = [];
4638
        foreach ($latestList as $w) {
4639
            $self = $w->getReflink();
4640
            $tokens = preg_split('/\s+/', trim((string)$w->getLinksto())) ?: [];
4641
            foreach ($tokens as $t) {
4642
                $t = trim($t);
4643
                if ($t === '' || $t === $self) {
4644
                    continue;
4645
                }
4646
                $linkedTokens[] = $t;
4647
            }
4648
        }
4649
        $linkedTokens = array_values(array_unique($linkedTokens));
4650
4651
        // Orphaned = pages not referenced by any token
4652
        $orphaned = array_values(array_diff($pages, $linkedTokens));
4653
4654
        $rows = [];
4655
        foreach ($orphaned as $ref) {
4656
            // Fetch one latest entity to check visibility/assignment/title
4657
            $latest = self::repo()->findOneBy(
4658
                ['cId' => $ctx['courseId'], 'reflink' => $ref, 'groupId' => (int)$ctx['groupId'], 'sessionId' => (int)$ctx['sessionId']],
4659
                ['version' => 'DESC', 'dtime' => 'DESC']
4660
            ) ?? self::repo()->findOneBy(['cId' => $ctx['courseId'], 'reflink' => $ref], ['version' => 'DESC']);
4661
4662
            if (!$latest) {
4663
                continue;
4664
            }
4665
            if ((!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) && (int)$latest->getVisibility() === 0) {
4666
                continue;
4667
            }
4668
4669
            $rows[] = [
4670
                self::assignmentIcon((int)$latest->getAssignment()),
4671
                Display::url(
4672
                    api_htmlentities($latest->getTitle()),
4673
                    $url.'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities($ref)])
4674
                ),
4675
            ];
4676
        }
4677
4678
        $table = new SortableTableFromArrayConfig($rows, 1, 10, 'OrphanedPages_table', '', '', 'ASC');
4679
        $table->set_additional_parameters([
4680
            'cid'     => $ctx['courseId'],
4681
            'gid'     => $ctx['groupId'],
4682
            'sid' => $ctx['sessionId'],
4683
            'action'     => Security::remove_XSS($this->action ?? 'orphaned'),
4684
        ]);
4685
        $table->set_header(0, get_lang('Type'),  true, ['style' => 'width:48px;']);
4686
        $table->set_header(1, get_lang('Title'), true);
4687
        $table->display();
4688
    }
4689
4690
    public function getWantedPages(): void
4691
    {
4692
        $ctx = self::ctx();
4693
        $url = $ctx['baseUrl'];
4694
4695
        echo '<div class="actions">'.get_lang('Wanted pages').'</div>';
4696
4697
        // Existing page names in context
4698
        $qbPages = self::repo()->createQueryBuilder('w')
4699
            ->select('DISTINCT w.reflink AS reflink')
4700
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4701
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4702
        if ($ctx['sessionId'] > 0) {
4703
            $qbPages->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4704
        } else {
4705
            $qbPages->andWhere('COALESCE(w.sessionId,0) = 0');
4706
        }
4707
        $pages = array_map(fn($r) => (string)$r['reflink'], $qbPages->getQuery()->getArrayResult());
4708
4709
        // Latest pages
4710
        $latestList = $this->getLatestPagesForContext();
4711
4712
        // Any token in linksto that is not an existing page -> wanted
4713
        $wanted = [];
4714
        foreach ($latestList as $w) {
4715
            $tokens = preg_split('/\s+/', trim((string)$w->getLinksto())) ?: [];
4716
            foreach ($tokens as $t) {
4717
                $t = trim($t);
4718
                if ($t === '') {
4719
                    continue;
4720
                }
4721
                if (!in_array($t, $pages, true)) {
4722
                    $wanted[] = $t;
4723
                }
4724
            }
4725
        }
4726
        $wanted = array_values(array_unique($wanted));
4727
4728
        $rows = [];
4729
        foreach ($wanted as $token) {
4730
            $token = Security::remove_XSS($token);
4731
            $rows[] = [
4732
                Display::url(
4733
                    str_replace('_', ' ', $token),
4734
                    $url.'&'.http_build_query(['action' => 'addnew', 'title' => str_replace('_', ' ', $token)]),
4735
                    ['class' => 'new_wiki_link']
4736
                ),
4737
            ];
4738
        }
4739
4740
        $table = new SortableTableFromArrayConfig($rows, 0, 10, 'WantedPages_table', '', '', 'ASC');
4741
        $table->set_additional_parameters([
4742
            'cid'     => $ctx['courseId'],
4743
            'gid'     => $ctx['groupId'],
4744
            'sid' => $ctx['sessionId'],
4745
            'action'     => Security::remove_XSS($this->action ?? 'wanted'),
4746
        ]);
4747
        $table->set_header(0, get_lang('Title'), true);
4748
        $table->display();
4749
    }
4750
4751
    public function getStats(): bool
4752
    {
4753
        if (!api_is_allowed_to_edit(false, true)) {
4754
            return false;
4755
        }
4756
4757
        $ctx = self::ctx();
4758
        echo '<div class="actions">'.get_lang('Statistics').'</div>';
4759
4760
        // Pull ALL versions in context (group/session/course) – no visibility filter (teachers)
4761
        $qbAll = self::repo()->createQueryBuilder('w')
4762
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
4763
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
4764
        if ($ctx['sessionId'] > 0) {
4765
            $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
4766
        } else {
4767
            $qbAll->andWhere('COALESCE(w.sessionId,0) = 0');
4768
        }
4769
        /** @var CWiki[] $allVersions */
4770
        $allVersions = $qbAll->getQuery()->getResult();
4771
4772
        // Latest version per reflink
4773
        $latestList = $this->getLatestPagesForContext();
4774
4775
        // ---- Aggregates across all versions ----
4776
        $total_versions          = count($allVersions);
4777
        $total_visits            = 0;
4778
        $total_words             = 0;
4779
        $total_links             = 0;
4780
        $total_links_anchors     = 0;
4781
        $total_links_mail        = 0;
4782
        $total_links_ftp         = 0;
4783
        $total_links_irc         = 0;
4784
        $total_links_news        = 0;
4785
        $total_wlinks            = 0;
4786
        $total_images            = 0;
4787
        $total_flash             = 0;
4788
        $total_mp3               = 0;
4789
        $total_flv               = 0;
4790
        $total_youtube           = 0;
4791
        $total_multimedia        = 0;
4792
        $total_tables            = 0;
4793
        $total_empty_content     = 0;
4794
        $total_comment_version   = 0;
4795
4796
        foreach ($allVersions as $w) {
4797
            $content = (string)$w->getContent();
4798
            $total_visits += (int)($w->getHits() ?? 0);
4799
            $total_words  += (int) self::word_count($content);
4800
            $total_links  += substr_count($content, 'href=');
4801
            $total_links_anchors += substr_count($content, 'href="#');
4802
            $total_links_mail    += substr_count($content, 'href="mailto');
4803
            $total_links_ftp     += substr_count($content, 'href="ftp');
4804
            $total_links_irc     += substr_count($content, 'href="irc');
4805
            $total_links_news    += substr_count($content, 'href="news');
4806
            $total_wlinks        += substr_count($content, '[[');
4807
            $total_images        += substr_count($content, '<img');
4808
            $clean_total_flash    = preg_replace('/player\.swf/', ' ', $content);
4809
            $total_flash         += substr_count((string)$clean_total_flash, '.swf"');
4810
            $total_mp3           += substr_count($content, '.mp3');
4811
            $total_flv           += (int) (substr_count($content, '.flv') / 5);
4812
            $total_youtube       += substr_count($content, 'http://www.youtube.com');
4813
            $total_multimedia    += substr_count($content, 'video/x-msvideo');
4814
            $total_tables        += substr_count($content, '<table');
4815
            if ($content === '') {
4816
                $total_empty_content++;
4817
            }
4818
            if ((string)$w->getComment() !== '') {
4819
                $total_comment_version++;
4820
            }
4821
        }
4822
4823
        // ---- Aggregates across latest version per page ----
4824
        $total_pages     = count($latestList);
4825
        $total_visits_lv = 0;
4826
        $total_words_lv  = 0;
4827
        $total_links_lv  = 0;
4828
        $total_links_anchors_lv = 0;
4829
        $total_links_mail_lv    = 0;
4830
        $total_links_ftp_lv     = 0;
4831
        $total_links_irc_lv     = 0;
4832
        $total_links_news_lv    = 0;
4833
        $total_wlinks_lv        = 0;
4834
        $total_images_lv        = 0;
4835
        $total_flash_lv         = 0;
4836
        $total_mp3_lv           = 0;
4837
        $total_flv_lv           = 0;
4838
        $total_youtube_lv       = 0;
4839
        $total_multimedia_lv    = 0;
4840
        $total_tables_lv        = 0;
4841
        $total_empty_content_lv = 0;
4842
4843
        $total_editing_now = 0;
4844
        $total_hidden      = 0;
4845
        $total_protected   = 0;
4846
        $total_lock_disc   = 0;
4847
        $total_hidden_disc = 0;
4848
        $total_only_teachers_rating = 0;
4849
        $total_task = 0;
4850
        $total_teacher_assignment = 0;
4851
        $total_student_assignment = 0;
4852
4853
        $score_sum = 0;
4854
        $progress_sum = 0;
4855
4856
        foreach ($latestList as $w) {
4857
            $content = (string)$w->getContent();
4858
4859
            $total_visits_lv += (int)($w->getHits() ?? 0);
4860
4861
            $total_words_lv  += (int) self::word_count($content);
4862
            $total_links_lv  += substr_count($content, 'href=');
4863
            $total_links_anchors_lv += substr_count($content, 'href="#');
4864
            $total_links_mail_lv    += substr_count($content, 'href="mailto');
4865
            $total_links_ftp_lv     += substr_count($content, 'href="ftp');
4866
            $total_links_irc_lv     += substr_count($content, 'href="irc');
4867
            $total_links_news_lv    += substr_count($content, 'href="news');
4868
            $total_wlinks_lv        += substr_count($content, '[[');
4869
            $total_images_lv        += substr_count($content, '<img');
4870
            $clean_total_flash      = preg_replace('/player\.swf/', ' ', $content);
4871
            $total_flash_lv         += substr_count((string)$clean_total_flash, '.swf"');
4872
            $total_mp3_lv           += substr_count($content, '.mp3');
4873
            $total_flv_lv           += (int) (substr_count($content, '.flv') / 5);
4874
            $total_youtube_lv       += substr_count($content, 'http://www.youtube.com');
4875
            $total_multimedia_lv    += substr_count($content, 'video/x-msvideo');
4876
            $total_tables_lv        += substr_count($content, '<table');
4877
            if ($content === '') {
4878
                $total_empty_content_lv++;
4879
            }
4880
4881
            // flags/counters from entity fields (latest only)
4882
            if ((int)$w->getIsEditing() !== 0) {
4883
                $total_editing_now++;
4884
            }
4885
            if ((int)$w->getVisibility() === 0) {
4886
                $total_hidden++;
4887
            }
4888
            if ((int)$w->getEditlock() === 1) {
4889
                $total_protected++;
4890
            }
4891
            if ((int)$w->getAddlockDisc() === 0) {
4892
                $total_lock_disc++;
4893
            }
4894
            if ((int)$w->getVisibilityDisc() === 0) {
4895
                $total_hidden_disc++;
4896
            }
4897
            if ((int)$w->getRatinglockDisc() === 0) {
4898
                $total_only_teachers_rating++;
4899
            }
4900
            if ((int)$w->getAssignment() === 1) {
4901
                $total_teacher_assignment++;
4902
            }
4903
            if ((int)$w->getAssignment() === 2) {
4904
                $total_student_assignment++;
4905
            }
4906
4907
            $conf = self::confRepo()->findOneBy(['cId' => $ctx['courseId'], 'pageId' => (int)$w->getPageId()]);
4908
            if ($conf && (string)$conf->getTask() !== '') {
4909
                $total_task++;
4910
            }
4911
4912
            $score_sum    += (int)($w->getScore() ?? 0);
4913
            $progress_sum += (int)($w->getProgress() ?? 0);
4914
        }
4915
4916
        $media_score    = $total_pages > 0 ? ($score_sum / $total_pages) : 0;
4917
        $media_progress = $total_pages > 0 ? ($progress_sum / $total_pages) : 0;
4918
4919
        // Student add new pages status (from any latest – addlock is uniform)
4920
        $wiki_add_lock = 0;
4921
        if (!empty($latestList)) {
4922
            $wiki_add_lock = (int)$latestList[0]->getAddlock();
4923
        }
4924
        $status_add_new_pag = $wiki_add_lock === 1 ? get_lang('Yes') : get_lang('No');
4925
4926
        // First and last wiki dates
4927
        $first_wiki_date = '';
4928
        $last_wiki_date  = '';
4929
        if (!empty($allVersions)) {
4930
            usort($allVersions, fn($a,$b) => $a->getDtime() <=> $b->getDtime());
4931
            $first_wiki_date = api_get_local_time($allVersions[0]->getDtime());
4932
            $last_wiki_date  = api_get_local_time($allVersions[count($allVersions)-1]->getDtime());
4933
        }
4934
4935
        // Total users / total IPs (across all versions)
4936
        $usersSet = [];
4937
        $ipSet    = [];
4938
        foreach ($allVersions as $w) {
4939
            $usersSet[(int)$w->getUserId()] = true;
4940
            $ipSet[(string)$w->getUserIp()] = true;
4941
        }
4942
        $total_users = count($usersSet);
4943
        $total_ip    = count($ipSet);
4944
4945
        // ---- Render tables ----
4946
4947
        echo '<table class="table table-hover table-striped data_table">';
4948
        echo '<thead><tr><th colspan="2">'.get_lang('General').'</th></tr></thead>';
4949
        echo '<tr><td>'.get_lang('StudentAddNewPages').'</td><td>'.$status_add_new_pag.'</td></tr>';
4950
        echo '<tr><td>'.get_lang('DateCreateOldestWikiPage').'</td><td>'.$first_wiki_date.'</td></tr>';
4951
        echo '<tr><td>'.get_lang('DateEditLatestWikiVersion').'</td><td>'.$last_wiki_date.'</td></tr>';
4952
        echo '<tr><td>'.get_lang('AverageScoreAllPages').'</td><td>'.$media_score.' %</td></tr>';
4953
        echo '<tr><td>'.get_lang('AverageMediaUserProgress').'</td><td>'.$media_progress.' %</td></tr>';
4954
        echo '<tr><td>'.get_lang('TotalWikiUsers').'</td><td>'.$total_users.'</td></tr>';
4955
        echo '<tr><td>'.get_lang('TotalIpAdress').'</td><td>'.$total_ip.'</td></tr>';
4956
        echo '</table><br/>';
4957
4958
        echo '<table class="table table-hover table-striped data_table">';
4959
        echo '<thead><tr><th colspan="2">'.get_lang('Pages').' '.get_lang('And').' '.get_lang('Versions').'</th></tr></thead>';
4960
        echo '<tr><td>'.get_lang('Pages').' - '.get_lang('NumContributions').'</td><td>'.$total_pages.' ('.get_lang('Versions').': '.$total_versions.')</td></tr>';
4961
        echo '<tr><td>'.get_lang('EmptyPages').'</td><td>'.$total_empty_content_lv.' ('.get_lang('Versions').': '.$total_empty_content.')</td></tr>';
4962
        echo '<tr><td>'.get_lang('NumAccess').'</td><td>'.$total_visits_lv.' ('.get_lang('Versions').': '.$total_visits.')</td></tr>';
4963
        echo '<tr><td>'.get_lang('TotalPagesEditedAtThisTime').'</td><td>'.$total_editing_now.'</td></tr>';
4964
        echo '<tr><td>'.get_lang('TotalHiddenPages').'</td><td>'.$total_hidden.'</td></tr>';
4965
        echo '<tr><td>'.get_lang('NumProtectedPages').'</td><td>'.$total_protected.'</td></tr>';
4966
        echo '<tr><td>'.get_lang('LockedDiscussPages').'</td><td>'.$total_lock_disc.'</td></tr>';
4967
        echo '<tr><td>'.get_lang('HiddenDiscussPages').'</td><td>'.$total_hidden_disc.'</td></tr>';
4968
        echo '<tr><td>'.get_lang('TotalComments').'</td><td>'.$total_comment_version.'</td></tr>';
4969
        echo '<tr><td>'.get_lang('TotalOnlyRatingByTeacher').'</td><td>'.$total_only_teachers_rating.'</td></tr>';
4970
        echo '<tr><td>'.get_lang('TotalRatingPeers').'</td><td>'.max(0, $total_pages - $total_only_teachers_rating).'</td></tr>';
4971
        echo '<tr><td>'.get_lang('TotalTeacherAssignments').' - '.get_lang('PortfolioMode').'</td><td>'.$total_teacher_assignment.'</td></tr>';
4972
        echo '<tr><td>'.get_lang('TotalStudentAssignments').' - '.get_lang('PortfolioMode').'</td><td>'.$total_student_assignment.'</td></tr>';
4973
        echo '<tr><td>'.get_lang('TotalTask').' - '.get_lang('StandardMode').'</td><td>'.$total_task.'</td></tr>';
4974
        echo '</table><br/>';
4975
4976
        echo '<table class="table table-hover table-striped data_table">';
4977
        echo '<thead>';
4978
        echo '<tr><th colspan="3">'.get_lang('ContentPagesInfo').'</th></tr>';
4979
        echo '<tr><td></td><td>'.get_lang('InTheLastVersion').'</td><td>'.get_lang('InAllVersions').'</td></tr>';
4980
        echo '</thead>';
4981
        echo '<tr><td>'.get_lang('NumWords').'</td><td>'.$total_words_lv.'</td><td>'.$total_words.'</td></tr>';
4982
        echo '<tr><td>'.get_lang('NumlinksHtmlImagMedia').'</td>'.
4983
            '<td>'.$total_links_lv.' ('.get_lang('Anchors').':'.$total_links_anchors_lv.', Mail:'.$total_links_mail_lv.', FTP:'.$total_links_ftp_lv.' IRC:'.$total_links_irc_lv.', News:'.$total_links_news_lv.')</td>'.
4984
            '<td>'.$total_links.' ('.get_lang('Anchors').':'.$total_links_anchors.', Mail:'.$total_links_mail.', FTP:'.$total_links_ftp.' IRC:'.$total_links_irc.', News:'.$total_links_news.')</td></tr>';
4985
        echo '<tr><td>'.get_lang('NumWikilinks').'</td><td>'.$total_wlinks_lv.'</td><td>'.$total_wlinks.'</td></tr>';
4986
        echo '<tr><td>'.get_lang('NumImages').'</td><td>'.$total_images_lv.'</td><td>'.$total_images.'</td></tr>';
4987
        echo '<tr><td>'.get_lang('NumFlash').'</td><td>'.$total_flash_lv.'</td><td>'.$total_flash.'</td></tr>';
4988
        echo '<tr><td>'.get_lang('NumMp3').'</td><td>'.$total_mp3_lv.'</td><td>'.$total_mp3.'</td></tr>';
4989
        echo '<tr><td>'.get_lang('NumFlvVideo').'</td><td>'.$total_flv_lv.'</td><td>'.$total_flv.'</td></tr>';
4990
        echo '<tr><td>'.get_lang('NumYoutubeVideo').'</td><td>'.$total_youtube_lv.'</td><td>'.$total_youtube.'</td></tr>';
4991
        echo '<tr><td>'.get_lang('NumOtherAudioVideo').'</td><td>'.$total_multimedia_lv.'</td><td>'.$total_multimedia.'</td></tr>';
4992
        echo '<tr><td>'.get_lang('NumTables').'</td><td>'.$total_tables_lv.'</td><td>'.$total_tables.'</td></tr>';
4993
        echo '</table>';
4994
4995
        return true;
4996
    }
4997
4998
    public function getStatsTable(): void
4999
    {
5000
        $ctx = self::ctx();
5001
        $url = $ctx['baseUrl'];
5002
5003
        // Breadcrumb
5004
        echo '<div class="wiki-bc-wrap">
5005
          <nav aria-label="breadcrumb">
5006
            <ol class="breadcrumb breadcrumb--wiki">
5007
              <li class="breadcrumb-item">
5008
                <a href="'.
5009
                    $this->url(['action'=>'showpage','title'=>'index']).'">'.
5010
                    Display::getMdiIcon(\Chamilo\CoreBundle\Enums\ActionIcon::HOME, 'mdi-inline', null, ICON_SIZE_SMALL, get_lang('Home')).
5011
                    '<span>'.get_lang('Wiki').'</span>
5012
                </a>
5013
              </li>
5014
              <li class="breadcrumb-item active" aria-current="page">'.
5015
                    Display::getMdiIcon(\Chamilo\CoreBundle\Enums\ActionIcon::VIEW_MORE, 'mdi-inline', null, ICON_SIZE_SMALL, get_lang('More')).
5016
                    '<span>'.get_lang('More').'</span>
5017
              </li>
5018
5019
              <div class="breadcrumb-actions">
5020
                <a class="btn btn-default btn-xs" href="'.$this->url().'">'.
5021
                    Display::getMdiIcon(\Chamilo\CoreBundle\Enums\ActionIcon::BACK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Back')).
5022
                    ' '.get_lang('Back').'
5023
                </a>
5024
              </div>
5025
            </ol>
5026
          </nav>
5027
        </div>';
5028
5029
        echo '<div class="row wiki-stats-grid">';
5030
5031
        // Column: “More”
5032
        echo '<div class="col-sm-6 col-md-4">
5033
            <div class="panel panel-default">
5034
              <div class="panel-heading"><strong>'.get_lang('More').'</strong></div>
5035
              <div class="panel-body">'.
5036
5037
            Display::url(
5038
                Display::getMdiIcon(ActionIcon::ADD_USER, 'ch-tool-icon', null, ICON_SIZE_MEDIUM)
5039
                .' '.get_lang('Most active users'),
5040
                $url.'&action=mactiveusers'
5041
            ).
5042
            Display::url(
5043
                Display::getMdiIcon(ActionIcon::HISTORY, 'ch-tool-icon', null, ICON_SIZE_MEDIUM)
5044
                .' '.get_lang('Most visited pages'),
5045
                $url.'&action=mvisited'
5046
            ).
5047
            Display::url(
5048
                Display::getMdiIcon(ActionIcon::REFRESH, 'ch-tool-icon', null, ICON_SIZE_MEDIUM)
5049
                .' '.get_lang('Most changed pages'),
5050
                $url.'&action=mostchanged'
5051
            ).
5052
5053
            '</div>
5054
            </div>
5055
          </div>';
5056
5057
        // Column: “Pages”
5058
        echo '<div class="col-sm-6 col-md-4">
5059
            <div class="panel panel-default">
5060
              <div class="panel-heading"><strong>'.get_lang('Pages').'</strong></div>
5061
              <div class="panel-body">'.
5062
5063
            Display::url(
5064
                Display::getMdiIcon(ActionIcon::CLOSE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM)
5065
                .' '.get_lang('Orphaned pages'),
5066
                $url.'&action=orphaned'
5067
            ).
5068
            Display::url(
5069
                Display::getMdiIcon(ActionIcon::STAR_OUTLINE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM)
5070
                .' '.get_lang('Wanted pages'),
5071
                $url.'&action=wanted'
5072
            ).
5073
            Display::url(
5074
                Display::getMdiIcon(ActionIcon::LINKS, 'ch-tool-icon', null, ICON_SIZE_MEDIUM)
5075
                .' '.get_lang('Most linked pages'),
5076
                $url.'&action=mostlinked'
5077
            ).
5078
5079
            '</div>
5080
            </div>
5081
          </div>';
5082
5083
        // Column: “Statistics” (admins/teachers)
5084
        echo '<div class="col-sm-12 col-md-4">
5085
            <div class="panel panel-default">
5086
              <div class="panel-heading"><strong>'.get_lang('Statistics').'</strong></div>
5087
              <div class="panel-body">';
5088
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
5089
            echo Display::url(
5090
                Display::getMdiIcon(ActionIcon::INFORMATION, 'ch-tool-icon', null, ICON_SIZE_MEDIUM)
5091
                .' '.get_lang('Statistics'),
5092
                $url.'&action=statistics'
5093
            );
5094
        } else {
5095
            echo '<span class="text-muted">'.get_lang('No data available').'</span>';
5096
        }
5097
        echo       '</div>
5098
            </div>
5099
          </div>';
5100
5101
        echo '</div>'; // row
5102
    }
5103
5104
    public function getLinks(string $page): void
5105
    {
5106
        $ctx   = self::ctx();
5107
        $url   = $ctx['baseUrl'];
5108
        $titleInGet = $_GET['title'] ?? null;
5109
5110
        if (!$titleInGet) {
5111
            Display::addFlash(Display::return_message(get_lang('MustSelectPage'), 'error', false));
5112
            return;
5113
        }
5114
5115
        // Normalize incoming page key (handle "Main page" ↔ index ↔ underscored)
5116
        $raw = html_entity_decode($page, ENT_QUOTES);
5117
        $reflink = WikiManager::normalizeReflink((string) $page);
5118
        $displayTitleLink = WikiManager::displayTokenFor($reflink);
5119
        if ($reflink === 'index') {
5120
            $displayTitleLink = str_replace(' ', '_', get_lang('Home'));
5121
        }
5122
5123
        // Load the target page (latest) to show its title and assignment icon
5124
        $target = self::repo()->findOneBy(
5125
            ['cId' => $ctx['courseId'], 'reflink' => $reflink, 'groupId' => (int)$ctx['groupId'], 'sessionId' => (int)$ctx['sessionId']],
5126
            ['version' => 'DESC', 'dtime' => 'DESC']
5127
        ) ?? self::repo()->findOneBy(['cId' => $ctx['courseId'], 'reflink' => $reflink], ['version' => 'DESC']);
5128
        if (!$target) {
5129
            Display::addFlash(Display::return_message(get_lang('Must select a page'), 'error', false));
5130
            return;
5131
        }
5132
5133
        $assignmentIcon = self::assignmentIcon((int)$target->getAssignment());
5134
5135
        echo '<div id="wikititle">'
5136
            .get_lang('LinksPagesFrom').": {$assignmentIcon} "
5137
            .Display::url(
5138
                api_htmlentities($target->getTitle()),
5139
                $url.'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities($reflink)])
5140
            )
5141
            .'</div>';
5142
5143
        // Build list of latest pages in context, then filter those whose linksto contains the token
5144
        $latestList = $this->getLatestPagesForContext();
5145
        $token = (string)$displayTitleLink; // tokens in linksto are space-separated
5146
5147
        $rows = [];
5148
        foreach ($latestList as $w) {
5149
            // Visibility gate for students
5150
            if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
5151
                if ((int)$w->getVisibility() !== 1) {
5152
                    continue;
5153
                }
5154
            }
5155
5156
            // match token in linksto (space-separated)
5157
            $tokens = preg_split('/\s+/', trim((string)$w->getLinksto())) ?: [];
5158
            if (!in_array($token, $tokens, true)) {
5159
                continue;
5160
            }
5161
5162
            // Row build
5163
            $userinfo = api_get_user_info($w->getUserId());
5164
            $author   = $userinfo !== false
5165
                ? UserManager::getUserProfileLink($userinfo)
5166
                : get_lang('Anonymous').' ('.api_htmlentities((string)$w->getUserIp()).')';
5167
5168
            $rows[] = [
5169
                self::assignmentIcon((int)$w->getAssignment()),
5170
                Display::url(
5171
                    api_htmlentities($w->getTitle()),
5172
                    $url.'&'.http_build_query(['action' => 'showpage', 'title' => api_htmlentities($w->getReflink())])
5173
                ),
5174
                $author,
5175
                api_get_local_time($w->getDtime()),
5176
            ];
5177
        }
5178
5179
        if (empty($rows)) {
5180
            return;
5181
        }
5182
5183
        $table = new SortableTableFromArrayConfig($rows, 1, 10, 'AllPages_table', '', '', 'ASC');
5184
        $table->set_additional_parameters([
5185
            'cid'     => $ctx['courseId'],
5186
            'gid'     => $ctx['groupId'],
5187
            'sid' => $ctx['sessionId'],
5188
            'action'     => Security::remove_XSS($this->action ?? 'links'),
5189
        ]);
5190
        $table->set_header(0, get_lang('Type'),   true, ['style' => 'width:30px;']);
5191
        $table->set_header(1, get_lang('Title'),  true);
5192
        $table->set_header(2, get_lang('Author'), true);
5193
        $table->set_header(3, get_lang('Date'),   true);
5194
        $table->display();
5195
    }
5196
5197
    public function exportTo(int $id, string $format = 'doc'): bool
5198
    {
5199
        $page = self::repo()->findOneBy(['iid' => $id]);
5200
        if ($page instanceof CWiki) {
5201
            $content = (string)$page->getContent();
5202
            $name    = (string)$page->getReflink();
5203
            if ($content !== '') {
5204
                Export::htmlToOdt($content, $name, $format);
5205
                return true;
5206
            }
5207
            return false;
5208
        }
5209
5210
        if (method_exists($this, 'getWikiDataFromDb')) {
5211
            $data = self::getWikiDataFromDb($id);
5212
            if (!empty($data['content'])) {
5213
                Export::htmlToOdt($data['content'], (string)$data['reflink'], $format);
5214
                return true;
5215
            }
5216
        }
5217
5218
        return false;
5219
    }
5220
5221
    public function export_to_pdf(int $id, string $course_code): bool
5222
    {
5223
        if (!api_is_platform_admin() && api_get_setting('students_export2pdf') !== 'true') {
5224
            Display::addFlash(
5225
                Display::return_message(get_lang('PDFDownloadNotAllowedForStudents'), 'error', false)
5226
            );
5227
            return false;
5228
        }
5229
5230
        $page = self::repo()->findOneBy(['iid' => $id]);
5231
        $titleRaw   = '';
5232
        $contentRaw = '';
5233
5234
        if ($page instanceof CWiki) {
5235
            $titleRaw   = (string) $page->getTitle();
5236
            $contentRaw = (string) $page->getContent();
5237
        } elseif (method_exists($this, 'getWikiDataFromDb')) {
5238
            $data = (array) self::getWikiDataFromDb($id);
5239
            $titleRaw   = (string) ($data['title']   ?? '');
5240
            $contentRaw = (string) ($data['content'] ?? '');
5241
        }
5242
5243
        if ($titleRaw === '' && $contentRaw === '') {
5244
            Display::addFlash(Display::return_message(get_lang('NoSearchResults'), 'error', false));
5245
            return false;
5246
        }
5247
5248
        $this->renderPdfFromHtmlDirect($titleRaw, $contentRaw, $course_code);
5249
        return true;
5250
    }
5251
5252
    /**
5253
     * Render PDF directly using mPDF (preferred) or Dompdf (fallback).
5254
     * If neither is installed, fall back to direct HTML download.
5255
     */
5256
    private function renderPdfFromHtmlDirect(string $title, string $content, string $courseCode): void
5257
    {
5258
        // Minimal safe print CSS (UTF-8, supports DejaVu Sans for wide Unicode)
5259
        $css = '
5260
        body{font-family:"DejaVu Sans",Arial,Helvetica,sans-serif;font-size:12pt;line-height:1.45;color:#222;margin:16px;}
5261
        h1,h2,h3{margin:0 0 10px;}
5262
        h1{font-size:20pt} h2{font-size:16pt} h3{font-size:14pt}
5263
        p{margin:0 0 8px;} img{max-width:100%;height:auto;}
5264
        .wiki-title{font-weight:bold;margin-bottom:12px;border-bottom:1px solid #ddd;padding-bottom:6px;}
5265
        .wiki-content{margin-top:8px;}
5266
        table{border-collapse:collapse} td,th{border:1px solid #ddd;padding:4px}
5267
        pre,code{font-family:Menlo,Consolas,monospace;font-size:10pt;white-space:pre-wrap;word-wrap:break-word}
5268
    ';
5269
5270
        // Fix relative course media inside content to absolute URLs
5271
        if (defined('REL_COURSE_PATH') && defined('WEB_COURSE_PATH')) {
5272
            if (api_strpos($content, '../..'.api_get_path(REL_COURSE_PATH)) !== false) {
5273
                $content = str_replace('../..'.api_get_path(REL_COURSE_PATH), api_get_path(WEB_COURSE_PATH), $content);
5274
            }
5275
        }
5276
5277
        // Sanitize title for document/file names
5278
        $safeTitle = trim($title) !== '' ? $title : 'wiki_page';
5279
        $downloadName = preg_replace('/\s+/', '_', (string) api_replace_dangerous_char($safeTitle)).'.pdf';
5280
5281
        // Wrap content (keep structure simple for HTML→PDF engines)
5282
        $html = '<!DOCTYPE html><html lang="'.htmlspecialchars(api_get_language_isocode()).'"><head>'
5283
            .'<meta charset="'.htmlspecialchars(api_get_system_encoding()).'">'
5284
            .'<title>'.htmlspecialchars($safeTitle).'</title>'
5285
            .'<style>'.$css.'</style>'
5286
            .'</head><body>'
5287
            .'<div class="wiki-title"><h1>'.htmlspecialchars($safeTitle).'</h1></div>'
5288
            .'<div class="wiki-content">'.$content.'</div>'
5289
            .'</body></html>';
5290
5291
        // --- Try mPDF first ---
5292
        if (class_exists('\\Mpdf\\Mpdf')) {
5293
            // Use mPDF directly
5294
            try {
5295
                $mpdf = new \Mpdf\Mpdf([
5296
                    'tempDir' => sys_get_temp_dir(),
5297
                    'mode'    => 'utf-8',
5298
                    'format'  => 'A4',
5299
                    'margin_left'   => 12,
5300
                    'margin_right'  => 12,
5301
                    'margin_top'    => 12,
5302
                    'margin_bottom' => 12,
5303
                ]);
5304
                $mpdf->SetTitle($safeTitle);
5305
                $mpdf->WriteHTML($html);
5306
                // Force download
5307
                $mpdf->Output($downloadName, 'D');
5308
                exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
5309
            } catch (\Throwable $e) {
5310
                // Continue to next engine
5311
            }
5312
        }
5313
5314
        // --- Try Dompdf fallback ---
5315
        if (class_exists('\\Dompdf\\Dompdf')) {
5316
            try {
5317
                $dompdf = new \Dompdf\Dompdf([
5318
                    'chroot' => realpath(__DIR__.'/../../..'),
5319
                    'isRemoteEnabled' => true,
5320
                ]);
5321
                $dompdf->loadHtml($html, 'UTF-8');
5322
                $dompdf->setPaper('A4', 'portrait');
5323
                $dompdf->render();
5324
                $dompdf->stream($downloadName, ['Attachment' => true]);
5325
                exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
5326
            } catch (\Throwable $e) {
5327
                // Continue to final fallback
5328
            }
5329
        }
5330
5331
        // --- Final fallback: deliver HTML as download (not PDF) ---
5332
        // Clean buffers to avoid header issues
5333
        if (function_exists('ob_get_level')) {
5334
            while (ob_get_level() > 0) { @ob_end_clean(); }
5335
        }
5336
        $htmlName = preg_replace('/\.pdf$/i', '.html', $downloadName);
5337
        header('Content-Type: text/html; charset='.api_get_system_encoding());
5338
        header('Content-Disposition: attachment; filename="'.$htmlName.'"');
5339
        header('X-Content-Type-Options: nosniff');
5340
        header('Cache-Control: no-store, no-cache, must-revalidate');
5341
        header('Pragma: no-cache');
5342
        echo $html;
5343
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
5344
    }
5345
5346
    public function getActiveUsers(string $action): void
5347
    {
5348
        echo '<div class="actions">'.get_lang('Most active users').'</div>';
5349
5350
        $courseId  = $this->currentCourseId();
5351
        $groupId   = $this->currentGroupId();
5352
        $sessionId = $this->currentSessionId();
5353
5354
        $data = $this->wikiRepo->countEditsByUser($courseId, $groupId, $sessionId);
5355
5356
        if (!$data) {
5357
            return;
5358
        }
5359
5360
        $rows = [];
5361
        foreach ($data as $row) {
5362
            $userId   = (int) $row['userId'];
5363
            $userIp   = (string) ($row['userIp'] ?? '');
5364
            $numEdits = (int) $row['numEdits'];
5365
5366
            $userInfo = $userId > 0 ? api_get_user_info($userId) : false;
5367
5368
            $authorCell = ($userId !== 0 && $userInfo !== false)
5369
                ? Display::url(
5370
                    $userInfo['complete_name_with_username'],
5371
                    $this->wikiUrl(['action' => 'usercontrib', 'user_id' => $userId])
5372
                )
5373
                : get_lang('Anonymous').' ('.api_htmlentities($userIp).')';
5374
5375
            $rows[] = [
5376
                $authorCell,
5377
                Display::url(
5378
                    (string) $numEdits,
5379
                    $this->wikiUrl(['action' => 'usercontrib', 'user_id' => $userId])
5380
                ),
5381
            ];
5382
        }
5383
5384
        $table = new SortableTableFromArrayConfig(
5385
            $rows,
5386
            1,
5387
            10,
5388
            'MostActiveUsersA_table',
5389
            '',
5390
            '',
5391
            'DESC'
5392
        );
5393
        $table->set_additional_parameters([
5394
            'cid'     => $_GET['cid']     ?? null,
5395
            'gid'     => $_GET['gid']     ?? null,
5396
            'sid' => $_GET['sid'] ?? null,
5397
            'action'     => Security::remove_XSS($action),
5398
        ]);
5399
        $table->set_header(0, get_lang('Author'), true);
5400
        $table->set_header(1, get_lang('Contributions'), true, ['style' => 'width:30px;']);
5401
        $table->display();
5402
    }
5403
5404
    /**
5405
     * Check & toggle “notify me by email” for a discussion.
5406
     * Returns current status: 0 (off) / 1 (on).
5407
     */
5408
    public function checkNotifyDiscuss(string $reflink): int
5409
    {
5410
        $ctx           = self::ctx();
5411
        $conn          = $this->conn();
5412
        $tblMailcue    = $this->tblWikiMailcue();
5413
        $linkCol       = $this->mailcueLinkColumn();
5414
        $userId        = (int) api_get_user_id();
5415
        $versionId     = $this->firstVersionIdByReflink($reflink);
5416
5417
        if (!$versionId) {
5418
            // If the page has no versions yet, there is nothing to toggle
5419
            return 0;
5420
        }
5421
5422
        // Read current status
5423
        $count = (int) $conn->fetchOne(
5424
            'SELECT COUNT(*) FROM '.$tblMailcue.'
5425
     WHERE c_id = :cid
5426
       AND '.$linkCol.' = :vid
5427
       AND user_id = :uid
5428
       AND type = :type
5429
       AND COALESCE(group_id,0)   = :gid
5430
       AND COALESCE(session_id,0) = :sid',
5431
            [
5432
                'cid'  => (int)$ctx['courseId'],
5433
                'vid'  => $versionId,
5434
                'uid'  => $userId,
5435
                'type' => 'D',
5436
                'gid'  => (int)$ctx['groupId'],
5437
                'sid'  => (int)$ctx['sessionId'],
5438
            ]
5439
        );
5440
5441
        $status = $count > 0 ? 1 : 0;
5442
5443
        // Toggle based on actionpage
5444
        $actionPage = $_GET['actionpage'] ?? null;
5445
5446
        if ($actionPage === 'locknotifydisc' && $status === 0) {
5447
            // Turn ON
5448
            $conn->insert($tblMailcue, [
5449
                'c_id'       => (int)$ctx['courseId'],
5450
                $linkCol     => $versionId,
5451
                'user_id'    => $userId,
5452
                'type'       => 'D',
5453
                'group_id'   => (int)$ctx['groupId'],
5454
                'session_id' => (int)$ctx['sessionId'],
5455
            ]);
5456
            $status = 1;
5457
        } elseif ($actionPage === 'unlocknotifydisc' && $status === 1) {
5458
            // Turn OFF
5459
            $conn->executeStatement(
5460
                'DELETE FROM '.$tblMailcue.'
5461
         WHERE c_id = :cid
5462
           AND '.$linkCol.' = :vid
5463
           AND user_id = :uid
5464
           AND type = :type
5465
           AND COALESCE(group_id,0)   = :gid
5466
           AND COALESCE(session_id,0) = :sid',
5467
                [
5468
                    'cid'  => (int)$ctx['courseId'],
5469
                    'vid'  => $versionId,
5470
                    'uid'  => $userId,
5471
                    'type' => 'D',
5472
                    'gid'  => (int)$ctx['groupId'],
5473
                    'sid'  => (int)$ctx['sessionId'],
5474
                ]
5475
            );
5476
            $status = 0;
5477
        }
5478
5479
        return $status;
5480
    }
5481
5482
    /**
5483
     * Build the Category create/edit form (Doctrine, Chamilo FormValidator).
5484
     */
5485
    private function createCategoryForm(?CWikiCategory $category = null): FormValidator
5486
    {
5487
        $em           = Container::getEntityManager();
5488
        $categoryRepo = $em->getRepository(CWikiCategory::class);
5489
5490
        $course  = api_get_course_entity();
5491
        $session = api_get_session_entity();
5492
5493
        // List of categories available in this course/session
5494
        $categories = $categoryRepo->findByCourse($course, $session);
5495
5496
        // Action URL using our url() helper (adds cidreq safely)
5497
        $form = new FormValidator(
5498
            'category',
5499
            'post',
5500
            $this->url(['action' => 'category', 'id' => $category ? $category->getId() : null])
5501
        );
5502
5503
        $form->addHeader(get_lang('AddCategory'));
5504
        // attributes array MUST be provided (empty array ok)
5505
        $form->addSelectFromCollection('parent', get_lang('Parent'), $categories, [], true, 'getNodeName');
5506
        $form->addText('name', get_lang('Name'));
5507
5508
        if ($category) {
5509
            $form->addButtonUpdate(get_lang('Update'));
5510
        } else {
5511
            $form->addButtonSave(get_lang('Save'));
5512
        }
5513
5514
        if ($form->validate()) {
5515
            $values = $form->exportValues();
5516
            $parent = !empty($values['parent']) ? $categoryRepo->find((int)$values['parent']) : null;
5517
5518
            if (!$category) {
5519
                $category = (new CWikiCategory())
5520
                    ->setCourse($course)
5521
                    ->setSession($session);
5522
                $em->persist($category);
5523
5524
                Display::addFlash(Display::return_message(get_lang('CategoryAdded'), 'success'));
5525
            } else {
5526
                Display::addFlash(Display::return_message(get_lang('CategoryEdited'), 'success'));
5527
            }
5528
5529
            $category
5530
                ->setName((string)$values['name'])
5531
                ->setParent($parent);
5532
5533
            $em->flush();
5534
5535
            header('Location: '.$this->url(['action' => 'category']));
5536
            exit;
5537
        }
5538
5539
        if ($category) {
5540
            $form->setDefaults([
5541
                'parent' => $category->getParent() ? $category->getParent()->getId() : 0,
5542
                'name'   => $category->getName(),
5543
            ]);
5544
        }
5545
5546
        return $form;
5547
    }
5548
5549
    /**
5550
     * Discussion screen for a wiki page (Doctrine/DBAL).
5551
     */
5552
    public function getDiscuss(string $page): void
5553
    {
5554
        $ctx  = self::ctx(api_get_course_int_id(), api_get_session_id(), api_get_group_id());
5555
        $em   = Container::getEntityManager();
5556
        $conn = $em->getConnection();
5557
5558
        // Session restriction
5559
        if ($ctx['sessionId'] !== 0 && api_is_allowed_to_session_edit(false, true) === false) {
5560
            api_not_allowed();
5561
            return;
5562
        }
5563
5564
        if (empty($_GET['title'])) {
5565
            Display::addFlash(Display::return_message(get_lang('MustSelectPage'), 'error', false));
5566
            return;
5567
        }
5568
5569
        $pageKey    = self::normalizeReflink($page);
5570
        $actionPage = $_GET['actionpage'] ?? null;
5571
5572
        // --- Inline toggles (PRG) ---
5573
        if ($actionPage) {
5574
            $cid  = (int)$ctx['courseId'];
5575
            $gid  = (int)$ctx['groupId'];
5576
            $sid  = (int)$ctx['sessionId'];
5577
            $uid  = (int)api_get_user_id();
5578
5579
            $predG = 'COALESCE(group_id,0) = :gid';
5580
            $predS = 'COALESCE(session_id,0) = :sid';
5581
5582
            switch ($actionPage) {
5583
                case 'lockdisc':
5584
                    $conn->executeStatement(
5585
                        "UPDATE c_wiki SET addlock_disc = 0
5586
                     WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
5587
                        ['cid'=>$cid,'r'=>$pageKey,'gid'=>$gid,'sid'=>$sid]
5588
                    );
5589
                    break;
5590
                case 'unlockdisc':
5591
                    $conn->executeStatement(
5592
                        "UPDATE c_wiki SET addlock_disc = 1
5593
                     WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
5594
                        ['cid'=>$cid,'r'=>$pageKey,'gid'=>$gid,'sid'=>$sid]
5595
                    );
5596
                    break;
5597
                case 'hidedisc':
5598
                    $conn->executeStatement(
5599
                        "UPDATE c_wiki SET visibility_disc = 0
5600
                     WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
5601
                        ['cid'=>$cid,'r'=>$pageKey,'gid'=>$gid,'sid'=>$sid]
5602
                    );
5603
                    break;
5604
                case 'showdisc':
5605
                    $conn->executeStatement(
5606
                        "UPDATE c_wiki SET visibility_disc = 1
5607
                     WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
5608
                        ['cid'=>$cid,'r'=>$pageKey,'gid'=>$gid,'sid'=>$sid]
5609
                    );
5610
                    break;
5611
                case 'lockrating':
5612
                    $conn->executeStatement(
5613
                        "UPDATE c_wiki SET ratinglock_disc = 0
5614
                     WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
5615
                        ['cid'=>$cid,'r'=>$pageKey,'gid'=>$gid,'sid'=>$sid]
5616
                    );
5617
                    break;
5618
                case 'unlockrating':
5619
                    $conn->executeStatement(
5620
                        "UPDATE c_wiki SET ratinglock_disc = 1
5621
                     WHERE c_id = :cid AND reflink = :r AND $predG AND $predS",
5622
                        ['cid'=>$cid,'r'=>$pageKey,'gid'=>$gid,'sid'=>$sid]
5623
                    );
5624
                    break;
5625
                case 'locknotifydisc':
5626
                    if (api_is_allowed_to_session_edit()) {
5627
                        $t = 'watchdisc:'.$pageKey;
5628
                        $conn->executeStatement(
5629
                            "INSERT INTO c_wiki_mailcue (c_id, group_id, session_id, user_id, type)
5630
                         SELECT :cid, :gid, :sid, :uid, :t FROM DUAL
5631
                         WHERE NOT EXISTS (
5632
                           SELECT 1 FROM c_wiki_mailcue
5633
                           WHERE c_id=:cid AND $predG AND $predS AND user_id=:uid AND type=:t
5634
                         )",
5635
                            ['cid'=>$cid,'gid'=>$gid,'sid'=>$sid,'uid'=>$uid,'t'=>$t]
5636
                        );
5637
                    }
5638
                    break;
5639
                case 'unlocknotifydisc':
5640
                    if (api_is_allowed_to_session_edit()) {
5641
                        $t = 'watchdisc:'.$pageKey;
5642
                        $conn->executeStatement(
5643
                            "DELETE FROM c_wiki_mailcue
5644
                         WHERE c_id=:cid AND $predG AND $predS AND user_id=:uid AND type=:t",
5645
                            ['cid'=>$cid,'gid'=>$gid,'sid'=>$sid,'uid'=>$uid,'t'=>$t]
5646
                        );
5647
                    }
5648
                    break;
5649
            }
5650
5651
            header('Location: '.$this->url(['action' => 'discuss', 'title' => $pageKey]));
5652
            exit;
5653
        }
5654
5655
        /** @var CWiki|null $last */
5656
        $last = self::repo()->createQueryBuilder('w')
5657
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
5658
            ->andWhere('w.reflink = :reflink')->setParameter('reflink', $pageKey)
5659
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
5660
            ->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId'])
5661
            ->orderBy('w.version', 'DESC')->setMaxResults(1)
5662
            ->getQuery()->getOneOrNullResult();
5663
5664
        /** @var CWiki|null $first */
5665
        $first = self::repo()->createQueryBuilder('w')
5666
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
5667
            ->andWhere('w.reflink = :reflink')->setParameter('reflink', $pageKey)
5668
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId'])
5669
            ->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId'])
5670
            ->orderBy('w.version', 'ASC')->setMaxResults(1)
5671
            ->getQuery()->getOneOrNullResult();
5672
5673
        if (!$last || !$first) {
5674
            Display::addFlash(Display::return_message(get_lang('DiscussNotAvailable'), 'normal', false));
5675
            return;
5676
        }
5677
5678
        $publicationId   = $first->getPageId() ?: (int)$first->getIid();
5679
        $lastVersionDate = api_get_local_time($last->getDtime());
5680
        $lastUserInfo    = api_get_user_info($last->getUserId());
5681
5682
        // New comment (PRG)
5683
        if (isset($_POST['Submit']) && self::double_post($_POST['wpost_id'] ?? '')) {
5684
            $nowUtc   = api_get_utc_datetime();
5685
            $authorId = (int) api_get_user_id();
5686
5687
            $conn->insert('c_wiki_discuss', [
5688
                'c_id'           => $ctx['courseId'],
5689
                'publication_id' => $publicationId,
5690
                'userc_id'       => $authorId,
5691
                'comment'        => (string)($_POST['comment'] ?? ''),
5692
                'p_score'        => (string)($_POST['rating'] ?? '-'),
5693
                'dtime'          => $nowUtc,
5694
            ]);
5695
5696
            self::check_emailcue($publicationId, 'D', $nowUtc, $authorId);
5697
5698
            header('Location: '.$this->url(['action' => 'discuss', 'title' => $pageKey]));
5699
            exit;
5700
        }
5701
5702
        // Assignment badge
5703
        $iconAssignment = null;
5704
        if ($last->getAssignment() === 1) {
5705
            $iconAssignment = Display::getMdiIcon(
5706
                ActionIcon::WIKI_ASSIGNMENT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('AssignmentDescExtra')
5707
            );
5708
        } elseif ($last->getAssignment() === 2) {
5709
            $iconAssignment = Display::getMdiIcon(
5710
                ActionIcon::WIKI_WORK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('AssignmentWorkExtra')
5711
            );
5712
        }
5713
5714
        echo '<div class="wiki-discuss"><div class="wd-wrap">';
5715
5716
        // Header
5717
        echo '<div class="wd-header">';
5718
        echo   '<div class="wd-titlebox">';
5719
        echo     '<h3 class="wd-title">'.$iconAssignment.'&nbsp;'.api_htmlentities($last->getTitle()).'</h3>';
5720
        if ($lastUserInfo !== false) {
5721
            echo   '<div class="wd-meta">'.get_lang('The latest version was edited by').' '
5722
                .UserManager::getUserProfileLink($lastUserInfo).' • '.$lastVersionDate.'</div>';
5723
        }
5724
        echo   '</div>';
5725
5726
        // Toolbar
5727
        echo   '<div class="wd-toolbar">';
5728
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
5729
            $addOpen   = (self::check_addlock_discuss($pageKey) === 1);
5730
            $lockIcon  = $addOpen
5731
                ? Display::getMdiIcon(ActionIcon::LOCK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('LockDiscussExtra'))
5732
                : Display::getMdiIcon(ActionIcon::UNLOCK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('UnlockDiscussExtra'));
5733
            $lockAction = $addOpen ? 'lockdisc' : 'unlockdisc';
5734
            echo Display::url($lockIcon, $this->url(['action'=>'discuss','actionpage'=>$lockAction,'title'=>$pageKey]));
5735
        }
5736
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
5737
            $isVisible = (self::check_visibility_discuss($pageKey) === 1);
5738
            $visIcon   = $isVisible
5739
                ? Display::getMdiIcon(ActionIcon::VISIBLE,   'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Hide'))
5740
                : Display::getMdiIcon(ActionIcon::INVISIBLE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Show'));
5741
            $visAction = $isVisible ? 'hidedisc' : 'showdisc';
5742
            echo Display::url($visIcon, $this->url(['action'=>'discuss','actionpage'=>$visAction,'title'=>$pageKey]));
5743
        }
5744
        if (api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
5745
            $ratingOn  = (self::check_ratinglock_discuss($pageKey) === 1);
5746
            $starIcon  = $ratingOn
5747
                ? Display::getMdiIcon(ActionIcon::STAR,         'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('LockRatingDiscussExtra'))
5748
                : Display::getMdiIcon(ActionIcon::STAR_OUTLINE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('UnlockRatingDiscussExtra'));
5749
            $rateAction = $ratingOn ? 'lockrating' : 'unlockrating';
5750
            echo Display::url($starIcon, $this->url(['action'=>'discuss','actionpage'=>$rateAction,'title'=>$pageKey]));
5751
        }
5752
        if ($this->mailcueLinkColumn() !== null) {
5753
            $notifyOn   = ($this->checkNotifyDiscuss($pageKey) === 1);
5754
            $notifyIcon = $notifyOn
5755
                ? Display::getMdiIcon(ActionIcon::EMAIL_ON,  'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('CancelNotifyMe'))
5756
                : Display::getMdiIcon(ActionIcon::EMAIL_OFF, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('NotifyMe'));
5757
            $notifyAction = $notifyOn ? 'unlocknotifydisc' : 'locknotifydisc';
5758
            echo Display::url($notifyIcon, $this->url(['action'=>'discuss','actionpage'=>$notifyAction,'title'=>$pageKey]));
5759
        }
5760
        echo   '</div>'; // wd-toolbar
5761
        echo '</div>';    // wd-header
5762
5763
        // Form
5764
        if ((int)$last->getAddlockDisc() === 1 || api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
5765
            echo '<div class="panel panel-default wd-card"><div class="panel-body">';
5766
            echo   '<form method="post" action="" class="form-horizontal wd-form">';
5767
            echo     '<input type="hidden" name="wpost_id" value="'.api_get_unique_id().'">';
5768
5769
            echo     '<div class="form-group">';
5770
            echo       '<label class="col-sm-2 control-label">'.get_lang('Comments').'</label>';
5771
            echo       '<div class="col-sm-10"><textarea class="form-control" name="comment" rows="4" placeholder="'.api_htmlentities(get_lang('Comments')).'"></textarea></div>';
5772
            echo     '</div>';
5773
5774
            echo     '<div class="form-group">';
5775
            if ((int)$last->getRatinglockDisc() === 1 || api_is_allowed_to_edit(false, true) || api_is_platform_admin()) {
5776
                echo   '<label class="col-sm-2 control-label">'.get_lang('Rating').'</label>';
5777
                echo   '<div class="col-sm-10"><select name="rating" class="form-control wd-rating">';
5778
                echo     '<option value="-" selected>-</option>';
5779
                for ($i=0; $i<=10; $i++) { echo '<option value="'.$i.'">'.$i.'</option>'; }
5780
                echo   '</select></div>';
5781
            } else {
5782
                echo   '<input type="hidden" name="rating" value="-">';
5783
                // Select disabled para mantener alineación
5784
                echo   '<label class="col-sm-2 control-label">'.get_lang('Rating').'</label>';
5785
                echo   '<div class="col-sm-10"><select class="form-control wd-rating" disabled><option>-</option></select></div>';
5786
            }
5787
            echo     '</div>';
5788
5789
            echo     '<div class="form-group"><div class="col-sm-offset-2 col-sm-10">';
5790
            echo       '<button class="btn btn--primary" type="submit" name="Submit">'.get_lang('Send').'</button>';
5791
            echo     '</div></div>';
5792
5793
            echo   '</form>';
5794
            echo '</div></div>';
5795
        }
5796
5797
        // Stats
5798
        $comments = $conn->executeQuery(
5799
            "SELECT d.* FROM c_wiki_discuss d
5800
         WHERE d.c_id = :cid AND d.publication_id = :pid
5801
         ORDER BY d.iid DESC",
5802
            ['cid'=>$ctx['courseId'], 'pid'=>$publicationId]
5803
        )->fetchAllAssociative();
5804
5805
        $countAll   = count($comments);
5806
        $scoredRows = (int)$conn->fetchOne(
5807
            "SELECT COUNT(*) FROM c_wiki_discuss
5808
         WHERE c_id = :cid AND publication_id = :pid AND p_score <> '-'",
5809
            ['cid'=>$ctx['courseId'], 'pid'=>$publicationId]
5810
        );
5811
        $sumRow = $conn->fetchAssociative(
5812
            "SELECT SUM(CASE WHEN p_score <> '-' THEN p_score END) AS sumWPost
5813
         FROM c_wiki_discuss WHERE c_id = :cid AND publication_id = :pid",
5814
            ['cid'=>$ctx['courseId'], 'pid'=>$publicationId]
5815
        );
5816
        $avgNumeric = ($scoredRows > 0) ? (float)$sumRow['sumWPost'] / $scoredRows : 0.0;
5817
5818
        echo '<div class="wd-stats">';
5819
        echo   '<span class="label label-default">'.get_lang('Comments on this page').': '.$countAll.'</span>';
5820
        echo   '<span class="label label-default">'.get_lang('Number of comments scored').': '.$scoredRows.'</span>';
5821
        echo   '<span class="label label-default">'.get_lang('The average rating for the page is').': '.number_format($avgNumeric, 2).' / 10</span>';
5822
        echo '</div>';
5823
5824
        // Persist score on wiki rows
5825
        $conn->executeStatement(
5826
            "UPDATE c_wiki SET score = :score
5827
         WHERE c_id = :cid AND reflink = :reflink
5828
           AND COALESCE(group_id,0) = :gid
5829
           AND COALESCE(session_id,0) = :sid",
5830
            [
5831
                'score'   => $avgNumeric,
5832
                'cid'     => $ctx['courseId'],
5833
                'reflink' => $pageKey,
5834
                'gid'     => (int)$ctx['groupId'],
5835
                'sid'     => (int)$ctx['sessionId'],
5836
            ]
5837
        );
5838
5839
        // Comments list
5840
        if ($countAll === 0) {
5841
            echo '<div class="well wd-empty">'.get_lang('NoSearchResults').'</div>';
5842
        } else {
5843
            foreach ($comments as $c) {
5844
                $uInfo  = api_get_user_info((int)$c['userc_id']);
5845
                $name   = $uInfo ? $uInfo['complete_name'] : get_lang('Anonymous');
5846
                $status = ($uInfo && (string)$uInfo['status'] === '5') ? get_lang('Student') : get_lang('Teacher');
5847
5848
                $photo  = ($uInfo && !empty($uInfo['avatar']))
5849
                    ? '<img class="wd-avatar" src="'.$uInfo['avatar'].'" alt="'.api_htmlentities($name).'">'
5850
                    : '<div class="wd-avatar wd-avatar--ph"></div>';
5851
5852
                $score = (string)$c['p_score'];
5853
                $stars = '';
5854
                if ($score !== '-' && ctype_digit($score)) {
5855
                    $map = [
5856
                        0=>'rating/stars_0.gif', 1=>'rating/stars_5.gif', 2=>'rating/stars_10.gif',
5857
                        3=>'rating/stars_15.gif',4=>'rating/stars_20.gif',5=>'rating/stars_25.gif',
5858
                        6=>'rating/stars_30.gif',7=>'rating/stars_35.gif',8=>'rating/stars_40.gif',
5859
                        9=>'rating/stars_45.gif',10=>'rating/stars_50.gif',
5860
                    ];
5861
                    $stars = Display::return_icon($map[(int)$score]);
5862
                }
5863
5864
                echo '<div class="wd-comment">';
5865
                echo   $photo;
5866
                echo   '<div class="wd-comment-body">';
5867
                $profileLink = $uInfo ? UserManager::getUserProfileLink($uInfo) : api_htmlentities($name);
5868
                echo   '<div class="wd-comment-meta">'.$profileLink.' <span class="wd-dot">•</span> '.$status.' <span class="wd-dot">•</span> '
5869
                    . api_get_local_time($c['dtime']).' <span class="wd-dot">•</span> '
5870
                    . get_lang('Rating').': '.$score.' '.$stars.'</div>';
5871
                echo   '<div class="wd-comment-text">'.api_htmlentities((string)$c['comment']).'</div>';
5872
                echo   '</div>';
5873
                echo '</div>';
5874
            }
5875
        }
5876
5877
        echo '</div></div>';
5878
    }
5879
5880
    public function export2doc(int $docId)
5881
    {
5882
        // Course & group context
5883
        $_course   = api_get_course_info();
5884
        $groupInfo = GroupManager::get_group_properties(api_get_group_id());
5885
5886
        // Try to get the wiki page
5887
        $page = self::repo()->findOneBy(['iid' => $docId]);
5888
        $data = [];
5889
        if ($page instanceof CWiki) {
5890
            $data = [
5891
                'title'   => (string) $page->getTitle(),
5892
                'content' => (string) $page->getContent(),
5893
            ];
5894
        } elseif (method_exists($this, 'getWikiDataFromDb')) {
5895
            // Backward-compat accessor
5896
            $data = (array) self::getWikiDataFromDb($docId);
5897
        }
5898
5899
        if (empty($data) || trim((string)($data['title'] ?? '')) === '') {
5900
            // Nothing to export
5901
            return false;
5902
        }
5903
5904
        $wikiTitle    = (string) $data['title'];
5905
        $wikiContents = (string) $data['content'];
5906
5907
        // XHTML wrapper (kept for old styles and Math support)
5908
        $template = <<<'HTML'
5909
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
5910
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="{LANGUAGE}" lang="{LANGUAGE}">
5911
<head>
5912
<title>{TITLE}</title>
5913
<meta http-equiv="Content-Type" content="text/html; charset={ENCODING}" />
5914
<style type="text/css" media="screen, projection">
5915
/*<![CDATA[*/
5916
{CSS}
5917
/*]]>*/
5918
</style>
5919
{ASCIIMATHML_SCRIPT}
5920
</head>
5921
<body dir="{TEXT_DIRECTION}">
5922
{CONTENT}
5923
</body>
5924
</html>
5925
HTML;
5926
5927
        // Resolve visual theme (avoid api_get_setting('stylesheets'))
5928
        $theme = 'chamilo';
5929
        if (function_exists('api_get_visual_theme')) {
5930
            $t = (string) api_get_visual_theme();
5931
            if ($t !== '') {
5932
                $theme = $t;
5933
            }
5934
        }
5935
5936
        // Load theme CSS (best-effort)
5937
        $cssFile = api_get_path(SYS_CSS_PATH).'themes/'.$theme.'/default.css';
5938
        $css     = file_exists($cssFile) ? (string) @file_get_contents($cssFile) : '';
5939
        if ($css === '') {
5940
            // Minimal fallback CSS to avoid a blank export
5941
            $css = 'body{font:14px/1.5 Arial,Helvetica,sans-serif;color:#222;padding:16px;}
5942
#wikititle h1{font-size:22px;margin:0 0 10px;}
5943
#wikicontent{margin-top:8px}
5944
img{max-width:100%;height:auto;}';
5945
        }
5946
5947
        // Fix paths in CSS so exported HTML works out of LMS
5948
        $rootRel = api_get_path(REL_PATH);
5949
        $css     = str_replace('behavior:url("/main/css/csshover3.htc");', '', $css);
5950
        $css     = str_replace('main/', $rootRel.'main/', $css);
5951
        $css     = str_replace('images/', $rootRel.'main/css/themes/'.$theme.'/images/', $css);
5952
        $css     = str_replace('../../img/', $rootRel.'main/img/', $css);
5953
5954
        // Math support if present in content
5955
        $asciiScript = (api_contains_asciimathml($wikiContents) || api_contains_asciisvg($wikiContents))
5956
            ? '<script src="'.api_get_path(WEB_CODE_PATH).'inc/lib/javascript/asciimath/ASCIIMathML.js" type="text/javascript"></script>'."\n"
5957
            : '';
5958
5959
        // Clean wiki links [[...]] → visible text only
5960
        $wikiContents = trim((string) preg_replace('/\[[\[]?([^\]|]*)[|]?([^|\]]*)\][\]]?/', '$1', $wikiContents));
5961
5962
        // Build final HTML
5963
        $html = str_replace(
5964
            ['{LANGUAGE}','{ENCODING}','{TEXT_DIRECTION}','{TITLE}','{CSS}','{ASCIIMATHML_SCRIPT}','{CONTENT}'],
5965
            [
5966
                api_get_language_isocode(),
5967
                api_get_system_encoding(),
5968
                api_get_text_direction(),
5969
                $wikiTitle,
5970
                $css,
5971
                $asciiScript,
5972
                $wikiContents
5973
            ],
5974
            $template
5975
        );
5976
5977
        // Replace relative course paths with absolute URLs (guard in case constant differs)
5978
        if (defined('REL_COURSE_PATH') && defined('WEB_COURSE_PATH')) {
5979
            if (api_strpos($html, '../..'.api_get_path(REL_COURSE_PATH)) !== false) {
5980
                $html = str_replace('../..'.api_get_path(REL_COURSE_PATH), api_get_path(WEB_COURSE_PATH), $html);
5981
            }
5982
        }
5983
5984
        // Compute a safe filename
5985
        $baseName = preg_replace('/\s+/', '_', (string) api_replace_dangerous_char($wikiTitle));
5986
        $downloadName = $baseName !== '' ? $baseName : 'wiki_page';
5987
        $downloadName .= '.html';
5988
5989
        // --- MODE A: Register in Document tool when SYS_COURSE_PATH exists ---
5990
        if (defined('SYS_COURSE_PATH')) {
5991
            $exportDir = rtrim(
5992
                api_get_path(SYS_COURSE_PATH).api_get_course_path().'/document'.($groupInfo['directory'] ?? ''),
5993
                '/'
5994
            );
5995
5996
            if (!is_dir($exportDir)) {
5997
                @mkdir($exportDir, 0775, true);
5998
            }
5999
6000
            // Ensure unique filename on disk
6001
            $i = 1;
6002
            do {
6003
                $fileName   = $baseName.'_'. $i .'.html';
6004
                $exportPath = $exportDir .'/'. $fileName;
6005
                $i++;
6006
            } while (file_exists($exportPath));
6007
6008
            file_put_contents($exportPath, $html);
6009
6010
            // Register in Document tool
6011
            $relativeDocPath = ($groupInfo['directory'] ?? '').'/'.$fileName;
6012
            $docId = add_document(
6013
                $_course,
6014
                $relativeDocPath,
6015
                'file',
6016
                (int) filesize($exportPath),
6017
                $wikiTitle
6018
            );
6019
6020
            api_item_property_update(
6021
                $_course,
6022
                TOOL_DOCUMENT,
6023
                $docId,
6024
                'DocumentAdded',
6025
                api_get_user_id(),
6026
                $groupInfo
6027
            );
6028
6029
            // Return doc id so caller can flash a confirmation
6030
            return $docId;
6031
        }
6032
6033
        // --- MODE B (fallback): Direct download (no Document registration) ---
6034
        // Clean existing buffers to avoid header issues
6035
        if (function_exists('ob_get_level')) {
6036
            while (ob_get_level() > 0) {
6037
                @ob_end_clean();
6038
            }
6039
        }
6040
6041
        header('Content-Type: text/html; charset='.api_get_system_encoding());
6042
        header('Content-Disposition: attachment; filename="'.$downloadName.'"');
6043
        header('X-Content-Type-Options: nosniff');
6044
        header('Cache-Control: no-store, no-cache, must-revalidate');
6045
        header('Pragma: no-cache');
6046
6047
6048
        echo $html;
6049
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
6050
    }
6051
6052
    /**
6053
     * Internal helper to render wiki HTML to PDF with headers/footers and wiki-link cleanup.
6054
     */
6055
    private function renderPdfFromHtml(string $titleRaw, string $contentRaw, string $courseCode): void
6056
    {
6057
        // Decode entities using platform encoding
6058
        $contentPdf = api_html_entity_decode($contentRaw, ENT_QUOTES, api_get_system_encoding());
6059
6060
        // Clean wiki links [[...]] -> visible text only (keep first capture)
6061
        $contentPdf = trim(preg_replace('/\[[\[]?([^\]|]*)[|]?([^|\]]*)\][\]]?/', '$1', $contentPdf));
6062
6063
        $titlePdf = api_html_entity_decode($titleRaw, ENT_QUOTES, api_get_system_encoding());
6064
6065
        // Ensure UTF-8 for mPDF pipeline
6066
        $titlePdf   = api_utf8_encode($titlePdf,   api_get_system_encoding());
6067
        $contentPdf = api_utf8_encode($contentPdf, api_get_system_encoding());
6068
6069
        $html = '
6070
<!-- defines the headers/footers - this must occur before the headers/footers are set -->
6071
<!--mpdf
6072
<pageheader name="odds" content-left="'.htmlspecialchars($titlePdf, ENT_QUOTES).'" header-style-left="color: #880000; font-style: italic;" line="1" />
6073
<pagefooter name="odds" content-right="{PAGENO}/{nb}" line="1" />
6074
<setpageheader name="odds" page="odd" value="on" show-this-page="1" />
6075
<setpagefooter name="odds" page="O" value="on" />
6076
mpdf-->'.$contentPdf;
6077
6078
        $css = api_get_print_css();
6079
6080
        $pdf = new PDF();
6081
        $pdf->content_to_pdf($html, $css, $titlePdf, $courseCode);
6082
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
6083
    }
6084
6085
    /**
6086
     * Helper: latest version of each page (respecting course/group/session; no visibility gate).
6087
     * @return CWiki[]
6088
     */
6089
    private function getLatestPagesForContext(): array
6090
    {
6091
        $ctx = self::ctx();
6092
        $em  = Container::getEntityManager();
6093
        $repo = self::repo();
6094
6095
        // Fetch distinct reflinks in context
6096
        $qbRef = $repo->createQueryBuilder('w')
6097
            ->select('DISTINCT w.reflink AS reflink')
6098
            ->andWhere('w.cId = :cid')->setParameter('cid', $ctx['courseId'])
6099
            ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', (int)$ctx['groupId']);
6100
        if ($ctx['sessionId'] > 0) {
6101
            $qbRef->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', (int)$ctx['sessionId']);
6102
        } else {
6103
            $qbRef->andWhere('COALESCE(w.sessionId,0) = 0');
6104
        }
6105
        $reflinks = array_map(fn($r) => (string)$r['reflink'], $qbRef->getQuery()->getArrayResult());
6106
6107
        $latest = [];
6108
        foreach ($reflinks as $ref) {
6109
            $page = $repo->findOneBy(
6110
                ['cId' => $ctx['courseId'], 'reflink' => $ref, 'groupId' => (int)$ctx['groupId'], 'sessionId' => (int)$ctx['sessionId']],
6111
                ['version' => 'DESC', 'dtime' => 'DESC']
6112
            ) ?? $repo->findOneBy(['cId' => $ctx['courseId'], 'reflink' => $ref], ['version' => 'DESC', 'dtime' => 'DESC']);
6113
            if ($page) {
6114
                $latest[] = $page;
6115
            }
6116
        }
6117
        return $latest;
6118
    }
6119
6120
    private function currentCourseId(): int
6121
    {
6122
        return (int) ( $_GET['cid'] ?? api_get_course_int_id() );
6123
    }
6124
6125
    private function currentGroupId(): ?int
6126
    {
6127
        $gid = $_GET['gid'] ?? api_get_group_id();
6128
        return $gid === null ? null : (int) $gid;
6129
    }
6130
6131
    private function currentSessionId(): ?int
6132
    {
6133
        $sid = $_GET['sid'] ?? api_get_session_id();
6134
        return $sid === null ? null : (int) $sid;
6135
    }
6136
6137
    private function wikiUrl(array $extra = []): string
6138
    {
6139
        $base = api_get_self();
6140
        $params = array_merge([
6141
            'cid' => $_GET['cid'] ?? null,
6142
            'gid' => $_GET['gid'] ?? null,
6143
            'sid' => $_GET['sid'] ?? null,
6144
        ], $extra);
6145
6146
        $params = array_filter($params, static fn($v) => $v !== null && $v !== '');
6147
6148
        return $base.'?'.http_build_query($params);
6149
    }
6150
6151
    /** Build base URL with current context (cid, gid, sid) */
6152
    private function computeBaseUrl(): string
6153
    {
6154
        $base = api_get_self();
6155
        $params = [
6156
            'cid'     => api_get_course_id(),
6157
            'gid'     => api_get_group_id(),
6158
            'sid' => api_get_session_id(),
6159
        ];
6160
6161
        return $base.'?'.http_build_query($params);
6162
    }
6163
6164
    /** Helper to create Wiki tool URLs */
6165
    private function url(array $params = []): string
6166
    {
6167
        return $this->baseUrl.($params ? '&'.http_build_query($params) : '');
6168
    }
6169
6170
    private function addCategory(): void
6171
    {
6172
        // --- Permissions & feature flag ---
6173
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
6174
            api_not_allowed(true);
6175
        }
6176
        if ('true' !== api_get_setting('wiki.wiki_categories_enabled')) {
6177
            api_not_allowed(true);
6178
        }
6179
6180
        // --- Repositories / context ---
6181
        $em           = Container::getEntityManager();
6182
        $categoryRepo = $em->getRepository(CWikiCategory::class);
6183
6184
        $course  = api_get_course_entity();
6185
        $session = api_get_session_entity();
6186
6187
        // --- If editing, make sure the category exists and belongs to the current course/session ---
6188
        $categoryToEdit = null;
6189
        if (isset($_GET['id'])) {
6190
            $categoryToEdit = $categoryRepo->find((int) $_GET['id']);
6191
            if (!$categoryToEdit) {
6192
                // English dev msg: Category not found in current repository
6193
                api_not_allowed(true);
6194
            }
6195
            if ($course !== $categoryToEdit->getCourse() || $session !== $categoryToEdit->getSession()) {
6196
                // English dev msg: Cross-course/session edition is not allowed
6197
                api_not_allowed(true);
6198
            }
6199
        }
6200
6201
        // --- Fetch categories for list ---
6202
        $categories = $categoryRepo->findByCourse($course, $session);
6203
6204
        // --- Action icons (MDI) ---
6205
        $iconEdit   = Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Edit'));
6206
        $iconDelete = Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Delete'));
6207
6208
        // --- Build rows for the table ---
6209
        $rows = array_map(function (CWikiCategory $category) use ($iconEdit, $iconDelete) {
6210
            $actions  = [];
6211
            $actions[] = Display::url(
6212
                $iconEdit,
6213
                $this->url(['action' => 'category', 'id' => $category->getId()])
6214
            );
6215
            $actions[] = Display::url(
6216
                $iconDelete,
6217
                $this->url(['action' => 'delete_category', 'id' => $category->getId()])
6218
            );
6219
6220
            return [
6221
                $category->getNodeName(),
6222
                implode(PHP_EOL, $actions),
6223
            ];
6224
        }, $categories);
6225
6226
        // --- Render form (create or edit) ---
6227
        $form = $this->createCategoryForm($categoryToEdit);
6228
        $form->display();
6229
6230
        echo '<hr/>';
6231
6232
        // --- Render table (name + actions) ---
6233
        $table = new SortableTableFromArrayConfig(
6234
            $rows,
6235
            0,
6236
            25,
6237
            'WikiCategories_table'
6238
        );
6239
        $table->set_header(0, get_lang('Name'), false);
6240
        $table->set_header(1, get_lang('Actions'), false, ['class' => 'text-right'], ['class' => 'text-right']);
6241
        $table->display();
6242
    }
6243
6244
    private function deleteCategory(): void
6245
    {
6246
        // --- Permissions & feature flag ---
6247
        if (!api_is_allowed_to_edit(false, true) && !api_is_platform_admin()) {
6248
            api_not_allowed(true);
6249
        }
6250
        if ('true' !== api_get_setting('wiki.wiki_categories_enabled')) {
6251
            api_not_allowed(true);
6252
        }
6253
6254
        $em = Container::getEntityManager();
6255
6256
        if (!isset($_GET['id'])) {
6257
            // English dev msg: Missing category id
6258
            api_not_allowed(true);
6259
        }
6260
6261
        /** @var CWikiCategory|null $category */
6262
        $category = $em->find(CWikiCategory::class, (int) $_GET['id']);
6263
        if (!$category) {
6264
            // English dev msg: Category not found
6265
            api_not_allowed(true);
6266
        }
6267
6268
        // --- Security: only allow removing categories in the current course/session ---
6269
        $course  = api_get_course_entity();
6270
        $session = api_get_session_entity();
6271
        if ($course !== $category->getCourse() || $session !== $category->getSession()) {
6272
            // English dev msg: Cross-course/session deletion is not allowed
6273
            api_not_allowed(true);
6274
        }
6275
6276
        // --- Delete and flush ---
6277
        $em->remove($category);
6278
        $em->flush();
6279
6280
        // --- UX feedback + redirect ---
6281
        Display::addFlash(
6282
            Display::return_message(get_lang('CategoryDeleted'), 'success')
6283
        );
6284
6285
        header('Location: '.$this->url(['action' => 'category']));
6286
        exit;
6287
    }
6288
6289
    /** Normalize a reflink into a stable key. Only 'index' is the main page. */
6290
    public static function normalizeReflink(?string $raw): string
6291
    {
6292
        if ($raw === null || $raw === '') {
6293
            return 'index';
6294
        }
6295
        $s = self::normalizeToken($raw);
6296
6297
        // Build aliases for the main page (both keys; fallback-safe)
6298
        $tHome         = (string) (get_lang('Home') ?: '');
6299
        $tDefaultTitle = (string) (get_lang('Home') ?: '');
6300
6301
        $aliases = array_filter([
6302
            'index',
6303
            self::normalizeToken($tHome),
6304
            self::normalizeToken($tDefaultTitle),
6305
        ]);
6306
6307
        if (in_array($s, $aliases, true)) {
6308
            return 'index';
6309
        }
6310
        return $s;
6311
    }
6312
6313
    /** Internal: apply the same normalization that we use for comparisons. */
6314
    private static function normalizeToken(string $t): string
6315
    {
6316
        $t = html_entity_decode($t, ENT_QUOTES);
6317
        $t = strip_tags(trim($t));
6318
        $t = mb_strtolower($t);
6319
        $t = strtr($t, [' ' => '_', '-' => '_']);
6320
        $t = preg_replace('/_+/', '_', $t);
6321
        return $t;
6322
    }
6323
6324
    /** True if the reflink is the main page. */
6325
    private static function isMain(string $reflink): bool
6326
    {
6327
        return $reflink === 'index';
6328
    }
6329
6330
    public static function displayTitleFor(string $reflink, ?string $dbTitle = null): string
6331
    {
6332
        if (self::isMain($reflink)) {
6333
            return get_lang('Home');
6334
        }
6335
        return $dbTitle !== null && $dbTitle !== '' ? $dbTitle : str_replace('_', ' ', $reflink);
6336
    }
6337
6338
    public static function displayTokenFor(string $reflink): string
6339
    {
6340
        if (self::isMain($reflink)) {
6341
            $label = get_lang('Home');
6342
            return str_replace(' ', '_', $label);
6343
        }
6344
        return $reflink;
6345
    }
6346
6347
    private static function dbg(string $msg): void
6348
    {
6349
        if (1) {
6350
            echo '<!-- WIKI DEBUG: '.htmlspecialchars($msg, ENT_QUOTES).' -->' . PHP_EOL;
6351
            error_log('[WIKI DEBUG] '.$msg);
6352
        }
6353
    }
6354
6355
    private static function utf8mb4_safe_entities(string $s): string {
6356
        return preg_replace_callback('/[\x{10000}-\x{10FFFF}]/u', static function($m) {
6357
            $cp = self::uniord($m[0]);
6358
            return sprintf('&#%d;', $cp);
6359
        }, $s);
6360
    }
6361
    private static function uniord(string $c): int {
6362
        $u = mb_convert_encoding($c, 'UCS-4BE', 'UTF-8');
6363
        $u = unpack('N', $u);
6364
        return $u[1];
6365
    }
6366
}
6367
6368
/** Backwards-compat shim so index.php can still `new Wiki()` */
6369
if (!class_exists('Wiki')) {
6370
    class_alias(WikiManager::class, 'Wiki');
6371
}
6372