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