1<?php
2/*********************************************************************
3    class.faq.php
4
5    Backend support for article creates, edits, deletes, and attachments.
6
7    Copyright (c)  2006-2013 osTicket
8    http://www.osticket.com
9
10    Released under the GNU General Public License WITHOUT ANY WARRANTY.
11    See LICENSE.TXT for details.
12
13    vim: expandtab sw=4 ts=4 sts=4:
14**********************************************************************/
15require_once('class.file.php');
16require_once('class.category.php');
17require_once('class.thread.php');
18
19class FAQ extends VerySimpleModel {
20
21    static $meta = array(
22        'table' => FAQ_TABLE,
23        'pk' => array('faq_id'),
24        'ordering' => array('question'),
25        'defer' => array('answer'),
26        'select_related'=> array('category'),
27        'joins' => array(
28            'category' => array(
29                'constraint' => array(
30                    'category_id' => 'Category.category_id'
31                ),
32            ),
33            'attachments' => array(
34                'constraint' => array(
35                    "'F'" => 'Attachment.type',
36                    'faq_id' => 'Attachment.object_id',
37                ),
38                'list' => true,
39                'null' => true,
40                'broker' => 'GenericAttachments',
41            ),
42            'topics' => array(
43                'reverse' => 'FaqTopic.faq',
44            ),
45        ),
46    );
47
48    const PERM_MANAGE  = 'faq.manage';
49    static protected $perms = array(
50            self::PERM_MANAGE => array(
51                'title' =>
52                /* @trans */ 'FAQ',
53                'desc'  =>
54                /* @trans */ 'Ability to add/update/disable/delete knowledgebase categories and FAQs',
55                'primary' => true,
56            ));
57
58    var $_local;
59    var $_attachments;
60
61    const VISIBILITY_PRIVATE = 0;
62    const VISIBILITY_PUBLIC = 1;
63    const VISIBILITY_FEATURED = 2;
64
65    /* ------------------> Getter methods <--------------------- */
66    function getId() { return $this->faq_id; }
67    function getHashtable() {
68        $base = $this->ht;
69        unset($base['category']);
70        unset($base['attachments']);
71        return $base;
72    }
73    function getKeywords() { return $this->keywords; }
74    function getQuestion() { return $this->question; }
75    function getAnswer() { return $this->answer; }
76    function getAnswerWithImages() {
77        return Format::viewableImages($this->answer, ['type' => 'F']);
78    }
79    function getTeaser() {
80        return Format::truncate(Format::striptags($this->answer), 150);
81    }
82    function getSearchableAnswer() {
83        return ThreadEntryBody::fromFormattedText($this->answer, 'html')
84            ->getSearchable();
85    }
86    function getNotes() { return $this->notes; }
87    function getNumAttachments() { return $this->attachments->count(); }
88
89    function isPublished() {
90        return $this->ispublished != self::VISIBILITY_PRIVATE
91            && $this->category->isPublic();
92    }
93    function getVisibilityDescription() {
94        switch ($this->ispublished) {
95        case self::VISIBILITY_PRIVATE:
96            return __('Internal');
97        case self::VISIBILITY_PUBLIC:
98            return __('Public');
99        case self::VISIBILITY_FEATURED:
100            return __('Featured');
101        }
102    }
103
104    function getCreateDate() { return $this->created; }
105    function getUpdateDate() { return $this->updated; }
106
107    function getCategoryId() { return $this->category_id; }
108    function getCategory() { return $this->category; }
109
110    function getHelpTopicsIds() {
111        $ids = array();
112        foreach ($this->getHelpTopics() as $T)
113            $ids[] = $T->topic->getId();
114        return $ids;
115    }
116
117    function getHelpTopicNames() {
118        $names = array();
119        foreach ($this->getHelpTopics() as $T)
120            $names[] = $T->topic->getFullName();
121        return $names;
122    }
123
124    function getHelpTopics() {
125        return $this->topics;
126    }
127
128    /* ------------------> Setter methods <--------------------- */
129    function setPublished($val) { $this->ispublished = !!$val; }
130    function setQuestion($question) { $this->question = Format::striptags(trim($question)); }
131    function setAnswer($text) { $this->answer = $text; }
132    function setKeywords($words) { $this->keywords = $words; }
133    function setNotes($text) { $this->notes = $text; }
134
135    function publish() {
136        $this->setPublished(1);
137        return $this->save();
138    }
139
140    function unpublish() {
141        $this->setPublished(0);
142        return $this->save();
143    }
144
145    function printPdf() {
146        global $thisstaff;
147        require_once(INCLUDE_DIR.'class.pdf.php');
148
149        $paper = 'Letter';
150        if ($thisstaff)
151            $paper = $thisstaff->getDefaultPaperSize();
152
153        ob_start();
154        $faq = $this;
155        include STAFFINC_DIR . 'templates/faq-print.tmpl.php';
156        $html = ob_get_clean();
157
158        $pdf = new mPDFWithLocalImages(['mode' => 'utf-8', 'format' =>
159               $paper, 'tempDir'=>sys_get_temp_dir()]);
160        // Setup HTML writing and load default thread stylesheet
161        $pdf->WriteHtml(
162            '<style>
163            .bleed { margin: 0; padding: 0; }
164            .faded { color: #666; }
165            .faq-title { font-size: 170%; font-weight: bold; }
166            .thread-body { font-family: serif; }'
167            .file_get_contents(ROOT_DIR.'css/thread.css')
168            .'</style>'
169            .'<div>'.$html.'</div>', 0, true, true);
170
171        $pdf->Output(Format::slugify($faq->getQuestion()) . '.pdf', 'I');
172    }
173
174    // Internationalization of the knowledge base
175
176    function getTranslateTag($subtag) {
177        return _H(sprintf('faq.%s.%s', $subtag, $this->getId()));
178    }
179    function getLocal($subtag) {
180        $tag = $this->getTranslateTag($subtag);
181        $T = CustomDataTranslation::translate($tag);
182        return $T != $tag ? $T : $this->ht[$subtag];
183    }
184    function getAllTranslations() {
185        if (!isset($this->_local)) {
186            $tag = $this->getTranslateTag('q:a');
187            $this->_local = CustomDataTranslation::allTranslations($tag, 'article');
188        }
189        return $this->_local;
190    }
191    function getLocalQuestion($lang=false) {
192        return $this->_getLocal('question', $lang);
193    }
194    function getLocalAnswer($lang=false) {
195        return $this->_getLocal('answer', $lang);
196    }
197    function getLocalAnswerWithImages($lang=false) {
198        return Format::viewableImages($this->getLocalAnswer($lang),
199                ['type' => 'F']);
200    }
201    function _getLocal($what, $lang=false) {
202        if (!$lang) {
203            $lang = $this->getDisplayLang();
204        }
205        $translations = $this->getAllTranslations();
206        foreach ($translations as $t) {
207            if (0 === strcasecmp($lang, $t->lang)) {
208                $data = $t->getComplex();
209                if (isset($data[$what]))
210                    return $data[$what];
211            }
212        }
213        return $this->ht[$what];
214    }
215    function getDisplayLang() {
216        if (isset($_REQUEST['kblang']))
217            $lang = $_REQUEST['kblang'];
218        else
219            $lang = Internationalization::getCurrentLanguage();
220        return $lang;
221    }
222
223    function getLocalAttachments($lang=false) {
224        return $this->attachments->getSeparates()->filter(Q::any(array(
225            'lang__isnull' => true,
226            'lang' => $lang ?: $this->getDisplayLang(),
227        )));
228    }
229
230    function updateTopics($ids){
231
232        if($ids) {
233            $topics = $this->getHelpTopicsIds();
234            foreach($ids as $id) {
235                if($topics && in_array($id,$topics)) continue;
236                $sql='INSERT IGNORE INTO '.FAQ_TOPIC_TABLE
237                    .' SET faq_id='.db_input($this->getId())
238                    .', topic_id='.db_input($id);
239                db_query($sql);
240            }
241        }
242
243        if ($ids)
244            $this->topics->filter(Q::not(array('topic_id__in' => $ids)))->delete();
245        else
246            $this->topics->delete();
247    }
248
249    function saveTranslations($vars) {
250        global $thisstaff;
251
252        foreach ($this->getAllTranslations() as $t) {
253            $trans = @$vars['trans'][$t->lang];
254            if (!$trans || !array_filter($trans))
255                // Not updating translations
256                continue;
257
258            // Content is not new and shouldn't be added below
259            unset($vars['trans'][$t->lang]);
260            $content = array('question' => $trans['question'],
261                'answer' => Format::sanitize($trans['answer']));
262
263            // Don't update content which wasn't updated
264            if ($content == $t->getComplex())
265                continue;
266
267            $t->text = $content;
268            $t->agent_id = $thisstaff->getId();
269            $t->updated = SqlFunction::NOW();
270            if (!$t->save())
271                return false;
272        }
273        // New translations (?)
274        $tag = $this->getTranslateTag('q:a');
275        foreach ($vars['trans'] as $lang=>$parts) {
276            $content = array('question' => @$parts['question'],
277                'answer' => Format::sanitize(@$parts['answer']));
278            if (!array_filter($content))
279                continue;
280            $t = CustomDataTranslation::create(array(
281                'type'      => 'article',
282                'object_hash' => $tag,
283                'lang'      => $lang,
284                'text'      => $content,
285                'revision'  => 1,
286                'agent_id'  => $thisstaff->getId(),
287                'updated'   => SqlFunction::NOW(),
288            ));
289            if (!$t->save())
290                return false;
291        }
292        return true;
293    }
294
295    function getAttachments($lang=null) {
296        $att = $this->attachments;
297        if ($lang)
298            $att = $att->window(array('lang' => $lang));
299        return $att;
300    }
301
302    function delete() {
303        try {
304            parent::delete();
305            $type = array('type' => 'deleted');
306            Signal::send('object.deleted', $this, $type);
307            // Cleanup help topics.
308            $this->topics->expunge();
309            // Cleanup attachments.
310            $this->attachments->deleteAll();
311        }
312        catch (OrmException $ex) {
313            return false;
314        }
315        return true;
316    }
317
318    /* ------------------> Static methods <--------------------- */
319
320    static function add($vars, &$errors) {
321        if(!($faq = self::create($vars)))
322            return false;
323
324        return $faq;
325    }
326
327    static function create($vars=false) {
328        $faq = new static($vars);
329        $faq->created = SqlFunction::NOW();
330        return $faq;
331    }
332
333    static function allPublic() {
334        return static::objects()->exclude(Q::any(array(
335            'ispublished'=>self::VISIBILITY_PRIVATE,
336            'category__ispublic'=>Category::VISIBILITY_PRIVATE,
337        )));
338    }
339
340    static function countPublishedFAQs() {
341        static $count;
342        if (!isset($count)) {
343            $count = self::allPublic()->count();
344        }
345        return $count;
346    }
347
348    static function getFeatured() {
349        return self::objects()
350            ->filter(array('ispublished__in'=>array(1,2), 'category__ispublic'=>1))
351            ->order_by('-ispublished');
352    }
353
354    static function findIdByQuestion($question) {
355        $row = self::objects()->filter(array(
356            'question'=>$question
357        ))->values_flat('faq_id')->first();
358
359        return ($row) ? $row[0] : null;
360    }
361
362    static function findByQuestion($question) {
363        return self::objects()->filter(array(
364            'question'=>$question
365        ))->one();
366    }
367
368    function update($vars, &$errors) {
369        global $cfg;
370
371        // Cleanup.
372        $vars['question'] = Format::striptags(trim($vars['question']));
373
374        // Validate
375        if ($vars['id'] && $this->getId() != $vars['id'])
376            $errors['err'] = __('Internal error occurred');
377        elseif (!$vars['question'])
378            $errors['question'] = __('Question required');
379        elseif (($qid=self::findIdByQuestion($vars['question'])) && $qid != $vars['id'])
380            $errors['question'] = __('Question already exists');
381
382        if (!$vars['category_id'] || !($category=Category::lookup($vars['category_id'])))
383            $errors['category_id'] = __('Category is required');
384
385        if (!$vars['answer'])
386            $errors['answer'] = __('FAQ answer is required');
387
388        if ($errors)
389            return false;
390
391        $this->question = $vars['question'];
392        $this->answer = Format::sanitize($vars['answer']);
393        $this->category = $category;
394        $this->ispublished = $vars['ispublished'];
395        $this->notes = Format::sanitize($vars['notes']);
396        $this->keywords = ' ';
397
398        if (!$this->save())
399            return false;
400
401        $this->updateTopics($vars['topics']);
402
403        // General attachments (for all languages)
404        // ---------------------
405        // Delete removed attachments.
406        if (isset($vars['files'])) {
407            $this->getAttachments()->keepOnlyFileIds($vars['files'], false);
408        }
409
410        $images = Draft::getAttachmentIds($vars['answer']);
411        $images = array_flip(array_map(function($i) { return $i['id']; }, $images));
412        $this->getAttachments()->keepOnlyFileIds($images, true);
413
414        // Handle language-specific attachments
415        // ----------------------
416        $langs = $cfg ? $cfg->getSecondaryLanguages() : false;
417        if ($langs) {
418            $langs[] = $cfg->getPrimaryLanguage();
419            foreach ($langs as $lang) {
420                if (!isset($vars['files_'.$lang]))
421                    // Not updating the FAQ
422                    continue;
423
424                $keepers = $vars['files_'.$lang];
425
426                // FIXME: Include inline images in translated content
427
428                $this->getAttachments($lang)->keepOnlyFileIds($keepers, false, $lang);
429            }
430        }
431
432        if (isset($vars['trans']) && !$this->saveTranslations($vars))
433            return false;
434
435        return true;
436    }
437
438    function save($refetch=false) {
439        if ($this->dirty)
440            $this->updated = SqlFunction::NOW();
441        return parent::save($refetch || $this->dirty);
442    }
443
444    static function getPermissions() {
445        return self::$perms;
446    }
447}
448
449RolePermission::register( /* @trans */ 'Knowledgebase',
450        FAQ::getPermissions());
451
452class FaqTopic extends VerySimpleModel {
453
454    static $meta = array(
455        'table' => FAQ_TOPIC_TABLE,
456        'pk' => array('faq_id', 'topic_id'),
457        'select_related' => 'topic',
458        'joins' => array(
459            'faq' => array(
460                'constraint' => array(
461                    'faq_id' => 'FAQ.faq_id',
462                ),
463            ),
464            'topic' => array(
465                'constraint' => array(
466                    'topic_id' => 'Topic.topic_id',
467                ),
468            ),
469        ),
470    );
471}
472
473class FaqAccessMgmtForm
474extends AbstractForm {
475    function buildFields() {
476        return array(
477            'ispublished' => new ChoiceField(array(
478                'label' => __('Listing Type'),
479                'choices' => array(
480                    FAQ::VISIBILITY_PRIVATE => __('Internal'),
481                    FAQ::VISIBILITY_PUBLIC => __('Public'),
482                    FAQ::VISIBILITY_FEATURED => __('Featured'),
483                ),
484            )),
485        );
486    }
487}
488