1<?php
2
3/**
4 * The main Tags class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at http://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @author    Matteo Scaramuccia <matteo@scaramuccia.com>
13 * @author    Georgi Korchev <korchev@yahoo.com>
14 * @copyright 2006-2020 phpMyFAQ Team
15 * @license   http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2006-08-10
18 */
19
20namespace phpMyFAQ;
21
22use phpMyFAQ\Entity\TagEntity as EntityTags;
23
24/**
25 * Class Tags
26 *
27 * @package phpMyFAQ
28 */
29class Tags
30{
31    /**
32     * @var Configuration
33     */
34    private $config;
35
36    /**
37     * @var array
38     */
39    private $recordsByTagName = [];
40
41    /**
42     * Constructor.
43     *
44     * @param Configuration $config
45     */
46    public function __construct(Configuration $config)
47    {
48        $this->config = $config;
49    }
50
51    /**
52     * Returns all tags for a FAQ record.
53     *
54     * @param int $recordId Record ID
55     * @return string
56     */
57    public function getAllLinkTagsById(int $recordId): string
58    {
59        $tagListing = '';
60
61        foreach ($this->getAllTagsById($recordId) as $taggingId => $taggingName) {
62            $title = Strings::htmlspecialchars($taggingName, ENT_QUOTES, 'utf-8');
63            $url = sprintf('%s?action=search&amp;tagging_id=%d', $this->config->getDefaultUrl(), $taggingId);
64            $oLink = new Link($url, $this->config);
65            $oLink->itemTitle = $taggingName;
66            $oLink->text = $taggingName;
67            $oLink->tooltip = $title;
68            $tagListing .= $oLink->toHtmlAnchor() . ', ';
69        }
70
71        return '' == $tagListing ? '-' : Strings::substr($tagListing, 0, -2);
72    }
73
74    /**
75     * Returns all tags for a FAQ record.
76     *
77     * @param int $recordId Record ID
78     * @return array
79     */
80    public function getAllTagsById(int $recordId): array
81    {
82        $tags = [];
83
84        $query = sprintf(
85            '
86            SELECT
87                dt.tagging_id AS tagging_id,
88                t.tagging_name AS tagging_name
89            FROM
90                %sfaqdata_tags dt, %sfaqtags t
91            WHERE
92                dt.record_id = %d
93            AND
94                dt.tagging_id = t.tagging_id
95            ORDER BY
96                t.tagging_name',
97            Database::getTablePrefix(),
98            Database::getTablePrefix(),
99            $recordId
100        );
101
102        $result = $this->config->getDb()->query($query);
103        if ($result) {
104            while ($row = $this->config->getDb()->fetchObject($result)) {
105                $tags[$row->tagging_id] = $row->tagging_name;
106            }
107        }
108
109        return $tags;
110    }
111
112    /**
113     * Saves all tags from a FAQ record.
114     *
115     * @param int $recordId Record ID
116     * @param array $tags Array of tags
117     * @return bool
118     */
119    public function saveTags(int $recordId, array $tags): bool
120    {
121        $currentTags = $this->getAllTags();
122
123        // Delete all tag references for the faq record
124        if (count($tags) > 0) {
125            $this->deleteTagsFromRecordId($recordId);
126        }
127
128        // Store tags and references for the faq record
129        foreach ($tags as $tagName) {
130            $tagName = trim($tagName);
131            if (Strings::strlen($tagName) > 0) {
132                if (
133                    !in_array(
134                        Strings::strtolower($tagName),
135                        array_map(['phpMyFAQ\Strings', 'strtolower'], $currentTags)
136                    )
137                ) {
138                    // Create the new tag
139                    $newTagId = $this->config->getDb()->nextId(Database::getTablePrefix() . 'faqtags', 'tagging_id');
140                    $query = sprintf(
141                        "INSERT INTO %sfaqtags (tagging_id, tagging_name) VALUES (%d, '%s')",
142                        Database::getTablePrefix(),
143                        $newTagId,
144                        $tagName
145                    );
146                    $this->config->getDb()->query($query);
147
148                    // Add the tag reference for the faq record
149                    $query = sprintf(
150                        'INSERT INTO %sfaqdata_tags (record_id, tagging_id) VALUES (%d, %d)',
151                        Database::getTablePrefix(),
152                        $recordId,
153                        $newTagId
154                    );
155                    $this->config->getDb()->query($query);
156                } else {
157                    // Add the tag reference for the faq record
158                    $query = sprintf(
159                        'INSERT INTO %sfaqdata_tags (record_id, tagging_id) VALUES (%d, %d)',
160                        Database::getTablePrefix(),
161                        $recordId,
162                        array_search(
163                            Strings::strtolower($tagName),
164                            array_map(['phpMyFAQ\Strings', 'strtolower'], $currentTags)
165                        )
166                    );
167                    $this->config->getDb()->query($query);
168                }
169            }
170        }
171
172        return true;
173    }
174
175    /**
176     * Returns all tags.
177     *
178     * @param string|null $search Move the returned result set to be the result of a start-with search
179     * @param int $limit Limit the returned result set
180     * @param bool $showInactive Show inactive tags
181     * @return array
182     */
183    public function getAllTags(
184        string $search = null,
185        int $limit = PMF_TAGS_CLOUD_RESULT_SET_SIZE,
186        bool $showInactive = false
187    ): array {
188        $allTags = [];
189
190        // Hack: LIKE is case sensitive under PostgreSQL
191        switch (Database::getType()) {
192            case 'pgsql':
193                $like = 'ILIKE';
194                break;
195            default:
196                $like = 'LIKE';
197                break;
198        }
199
200        $query = sprintf(
201            '
202            SELECT
203                MIN(t.tagging_id) AS tagging_id, t.tagging_name AS tagging_name
204            FROM
205                %sfaqtags t
206            LEFT JOIN
207                %sfaqdata_tags dt
208            ON
209                dt.tagging_id = t.tagging_id
210            LEFT JOIN
211                %sfaqdata d
212            ON
213                d.id = dt.record_id
214            WHERE
215                1=1
216                %s
217                %s
218            GROUP BY
219                tagging_name
220            ORDER BY
221                tagging_name ASC',
222            Database::getTablePrefix(),
223            Database::getTablePrefix(),
224            Database::getTablePrefix(),
225            ($showInactive ? '' : "AND d.active = 'yes'"),
226            (isset($search) && ($search != '') ? 'AND tagging_name ' . $like . " '" . $search . "%'" : '')
227        );
228
229        $result = $this->config->getDb()->query($query);
230
231        if ($result) {
232            $i = 0;
233            while ($row = $this->config->getDb()->fetchObject($result)) {
234                if ($i < $limit) {
235                    $allTags[$row->tagging_id] = $row->tagging_name;
236                } else {
237                    break;
238                }
239                ++$i;
240            }
241        }
242
243        return array_unique($allTags);
244    }
245
246    /**
247     * Deletes all tags from a given record id.
248     *
249     * @param int $recordId Record ID
250     * @return bool
251     */
252    public function deleteTagsFromRecordId(int $recordId): bool
253    {
254        $query = sprintf(
255            'DELETE FROM %sfaqdata_tags WHERE record_id = %d',
256            Database::getTablePrefix(),
257            $recordId
258        );
259
260        $this->config->getDb()->query($query);
261
262        return true;
263    }
264
265    /**
266     * Updates a tag.
267     *
268     * @param EntityTags $entity
269     * @return bool
270     */
271    public function updateTag(EntityTags $entity): bool
272    {
273        $query = sprintf(
274            "UPDATE %sfaqtags SET tagging_name = '%s' WHERE tagging_id = %d",
275            Database::getTablePrefix(),
276            $entity->getName(),
277            $entity->getId()
278        );
279
280        return $this->config->getDb()->query($query);
281    }
282
283    /**
284     * Deletes a given tag.
285     *
286     * @param int $tagId
287     * @return bool
288     */
289    public function deleteTag(int $tagId): bool
290    {
291        $query = sprintf(
292            'DELETE FROM %sfaqtags WHERE tagging_id = %d',
293            Database::getTablePrefix(),
294            $tagId
295        );
296
297        $this->config->getDb()->query($query);
298
299        $query = sprintf(
300            'DELETE FROM %sfaqdata_tags WHERE tagging_id = %d',
301            Database::getTablePrefix(),
302            $tagId
303        );
304
305        $this->config->getDb()->query($query);
306
307        return true;
308    }
309
310    /**
311     * Returns the FAQ record IDs where all tags are included.
312     *
313     * @param array $arrayOfTags Array of Tags
314     * @return array
315     */
316    public function getFaqsByIntersectionTags(array $arrayOfTags): array
317    {
318        $query = sprintf(
319            "
320            SELECT
321                td.record_id AS record_id
322            FROM
323                %sfaqdata_tags td
324            JOIN
325                %sfaqtags t ON (td.tagging_id = t.tagging_id)
326            JOIN
327                %sfaqdata d ON (td.record_id = d.id)
328            WHERE
329                (t.tagging_name IN ('%s'))
330            AND
331                (d.lang = '%s')
332            GROUP BY
333                td.record_id
334            HAVING
335                COUNT(td.record_id) = %d",
336            Database::getTablePrefix(),
337            Database::getTablePrefix(),
338            Database::getTablePrefix(),
339            implode("', '", $arrayOfTags),
340            $this->config->getLanguage()->getLanguage(),
341            count($arrayOfTags)
342        );
343
344        $records = [];
345        $result = $this->config->getDb()->query($query);
346        while ($row = $this->config->getDb()->fetchObject($result)) {
347            $records[] = $row->record_id;
348        }
349
350        return $records;
351    }
352
353    /**
354     * Returns the HTML for the Tags Cloud.
355     *
356     * @return string
357     */
358    public function renderTagCloud()
359    {
360        $tags = [];
361
362        // Limit the result set (see: PMF_TAGS_CLOUD_RESULT_SET_SIZE)
363        // for avoiding an 'heavy' load during the evaluation
364        // of the number of records for each tag
365        $tagList = $this->getAllTags('', PMF_TAGS_CLOUD_RESULT_SET_SIZE);
366
367        foreach ($tagList as $tagId => $tagName) {
368            $totFaqByTag = count($this->getFaqsByTagName($tagName));
369            if ($totFaqByTag > 0) {
370                $tags[$tagName]['id'] = $tagId;
371                $tags[$tagName]['name'] = $tagName;
372                $tags[$tagName]['count'] = $totFaqByTag;
373            }
374        }
375        $min = 0;
376        $max = 0;
377        foreach ($tags as $tag) {
378            if ($min > $tag['count']) {
379                $min = $tag['count'];
380            }
381            if ($max < $tag['count']) {
382                $max = $tag['count'];
383            }
384        }
385
386        $html = '';
387        $i = 0;
388        foreach ($tags as $tag) {
389            ++$i;
390            $title = Strings::htmlspecialchars($tag['name'] . ' (' . $tag['count'] . ')', ENT_QUOTES, 'utf-8');
391            $url = sprintf('%s?action=search&amp;tagging_id=%d', $this->config->getDefaultUrl(), $tag['id']);
392            $oLink = new Link($url, $this->config);
393            $oLink->itemTitle = $tag['name'];
394            $oLink->text = $tag['name'];
395            $oLink->tooltip = $title;
396            $oLink->class = 'btn btn-primary m-1';
397            $html .= $oLink->toHtmlAnchor();
398            $html .= (count($tags) == $i ? '' : ' ');
399        }
400
401        return $html;
402    }
403
404    /**
405     * Returns all FAQ record IDs where all tags are included.
406     *
407     * @param string $tagName The name of the tag
408     * @return array
409     */
410    public function getFaqsByTagName(string $tagName): array
411    {
412        if (count($this->recordsByTagName)) {
413            return $this->recordsByTagName;
414        }
415
416        $query = sprintf(
417            "
418            SELECT
419                dt.record_id AS record_id
420            FROM
421                %sfaqtags t, %sfaqdata_tags dt
422            LEFT JOIN
423                %sfaqdata d
424            ON
425                d.id = dt.record_id
426            WHERE
427                t.tagging_id = dt.tagging_id
428            AND
429                t.tagging_name = '%s'",
430            Database::getTablePrefix(),
431            Database::getTablePrefix(),
432            Database::getTablePrefix(),
433            $this->config->getDb()->escape($tagName)
434        );
435
436        $this->recordsByTagName = [];
437        $result = $this->config->getDb()->query($query);
438        while ($row = $this->config->getDb()->fetchObject($result)) {
439            $this->recordsByTagName[] = $row->record_id;
440        }
441
442        return $this->recordsByTagName;
443    }
444
445    /**
446     * Returns all FAQ record IDs where all tags are included.
447     *
448     * @param int $tagId Tagging ID
449     * @return array
450     */
451    public function getFaqsByTagId(int $tagId): array
452    {
453        $query = sprintf(
454            '
455            SELECT
456                d.record_id AS record_id
457            FROM
458                %sfaqdata_tags d, %sfaqtags t
459            WHERE
460                t.tagging_id = d.tagging_id
461            AND
462                t.tagging_id = %d
463            GROUP BY
464                record_id',
465            Database::getTablePrefix(),
466            Database::getTablePrefix(),
467            $tagId
468        );
469
470        $records = [];
471        $result = $this->config->getDb()->query($query);
472        while ($row = $this->config->getDb()->fetchObject($result)) {
473            $records[] = $row->record_id;
474        }
475
476        return $records;
477    }
478
479    /**
480     * @param int $limit
481     * @return string
482     */
483    public function renderPopularTags(int $limit = 0): string
484    {
485        $html = '';
486        foreach ($this->getPopularTags($limit) as $tagId => $tagFreq) {
487            $tagName = $this->getTagNameById($tagId);
488            $html .= sprintf(
489                '<a class="btn btn-primary m-1" href="?action=search&tagging_id=%d">%s ' .
490                '<span class="badge badge-info">%d</span></a>',
491                $tagId,
492                $tagName,
493                $tagFreq
494            );
495        }
496
497        return $html;
498    }
499
500    /**
501     * @param int $limit Specify the maximum amount of records to return
502     * @return array $tagId => $tagFrequency
503     */
504    public function getPopularTags($limit = 0)
505    {
506        $tags = [];
507
508        $query = sprintf(
509            "
510            SELECT
511                COUNT(record_id) as freq, tagging_id
512            FROM
513                %sfaqdata_tags
514            JOIN
515                %sfaqdata ON id = record_id
516            WHERE
517              lang = '%s'
518            GROUP BY tagging_id
519            ORDER BY freq DESC",
520            Database::getTablePrefix(),
521            Database::getTablePrefix(),
522            $this->config->getLanguage()->getLanguage()
523        );
524
525        $result = $this->config->getDb()->query($query);
526
527        if ($result) {
528            while ($row = $this->config->getDb()->fetchObject($result)) {
529                $tags[$row->tagging_id] = $row->freq;
530                if (--$limit === 0) {
531                    break;
532                }
533            }
534        }
535
536        return $tags;
537    }
538
539    /**
540     * Returns the tagged item.
541     *
542     * @param int $tagId Tagging ID
543     * @return string
544     */
545    public function getTagNameById(int $tagId): string
546    {
547        $query = sprintf(
548            'SELECT tagging_name FROM %sfaqtags WHERE tagging_id = %d',
549            Database::getTablePrefix(),
550            $tagId
551        );
552
553        $result = $this->config->getDb()->query($query);
554        if ($row = $this->config->getDb()->fetchObject($result)) {
555            return $row->tagging_name;
556        }
557
558        return '';
559    }
560
561    /**
562     * Returns the popular Tags as an array
563     *
564     * @param int $limit
565     * @return array
566     */
567    public function getPopularTagsAsArray(int $limit = 0): array
568    {
569        $data = [];
570        foreach ($this->getPopularTags($limit) as $tagId => $tagFreq) {
571            $tagName = $this->getTagNameById($tagId);
572            $data[] = [
573                'tagId' => (int)$tagId,
574                'tagName' => $tagName,
575                'tagFrequency' => (int)$tagFreq
576            ];
577        }
578
579        return $data;
580    }
581}
582