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

WikiManager::showDiscuss()   F

Complexity

Conditions 46
Paths > 20000

Size

Total Lines 210
Code Lines 152

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 46
eloc 152
nc 47174448
nop 1
dl 0
loc 210
rs 0
c 1
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
/* 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