From deb2d0e98f82f57295bf4a727af1ae69f7f5a9de Mon Sep 17 00:00:00 2001
From: Peter Rotich <peter@osticket.com>
Date: Sat, 18 Mar 2017 19:09:30 +0000
Subject: [PATCH] Add ability to nest Knowledgebase Categories

Introduce ability to create sub-categories to knowledge base categories
---
 include/class.category.php           | 92 ++++++++++++++++++++++++++--
 include/client/faq-category.inc.php  | 22 +++++--
 include/client/faq.inc.php           |  7 ++-
 include/client/kb-categories.inc.php | 48 ++++++++++++---
 include/staff/categories.inc.php     |  2 +-
 include/staff/category.inc.php       | 18 ++++++
 include/staff/faq-categories.inc.php | 26 ++++++--
 include/staff/faq-category.inc.php   | 22 +++++--
 include/staff/faq-view.inc.php       |  3 +-
 include/staff/faq.inc.php            |  2 +-
 10 files changed, 209 insertions(+), 33 deletions(-)

diff --git a/include/class.category.php b/include/class.category.php
index d8cbb72f2..9a8e1b6d3 100644
--- a/include/class.category.php
+++ b/include/class.category.php
@@ -21,9 +21,16 @@ class Category extends VerySimpleModel {
         'pk' => array('category_id'),
         'ordering' => array('name'),
         'joins' => array(
+            'parent' => array(
+                'constraint' => array('category_pid' => 'Category.category_id'),
+                'null' => true,
+                ),
+            'children' => array(
+                'reverse' => 'Category.parent',
+                ),
             'faqs' => array(
                 'reverse' => 'FAQ.category'
-            ),
+                ),
         ),
     );
 
@@ -36,6 +43,9 @@ class Category extends VerySimpleModel {
     /* ------------------> Getter methods <--------------------- */
     function getId() { return $this->category_id; }
     function getName() { return $this->name; }
+    function getFullName() {
+        return self::getNameById($this->category_id) ?: $this->getLocalName();
+    }
     function getNumFAQs() { return  $this->faqs->count(); }
     function getDescription() { return $this->description; }
     function getDescriptionWithImages() { return Format::viewableImages($this->description); }
@@ -109,6 +119,29 @@ class Category extends VerySimpleModel {
             ->limit(5);
     }
 
+    function getPublicSubCategories() {
+        return $this->getSubCategories(array('public' => true));
+    }
+
+    function getSubCategories($criteria=array()) {
+
+        $categories = self::objects()
+            ->filter(array('category_pid' => $this->getId()));
+        if (isset($criteria['public']) && $categories) {
+            $categories
+                ->exclude(
+                    Q::any(array(
+                            'ispublic'=>Category::VISIBILITY_PRIVATE,
+                            'faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE,
+                            )))
+                ->annotate(array(
+                            'faq_count'=>SqlAggregate::COUNT('faqs')))
+                ->filter(array('faq_count__gt'=>0));
+        }
+
+        return $categories;
+    }
+
     /* ------------------> Setter methods <--------------------- */
     function setName($name) { $this->name=$name; }
     function setNotes($notes) { $this->notes=$notes; }
@@ -128,7 +161,7 @@ class Category extends VerySimpleModel {
             $errors['name'] = __('Category name is required');
         elseif (strlen($vars['name']) < 3)
             $errors['name'] = __('Name is too short. 3 chars minimum');
-        elseif (($cid=self::findIdByName($vars['name'])) && $cid != $vars['id'])
+        elseif (($cid=self::findIdByName($vars['name'], $vars['pid'])) && $cid != $vars['id'])
             $errors['name'] = __('Category already exists');
 
         if (!$vars['description'])
@@ -143,6 +176,7 @@ class Category extends VerySimpleModel {
 
         $this->ispublic = $vars['ispublic'];
         $this->name = $vars['name'];
+        $this->category_pid = $vars['pid'] ?: 0;
         $this->description = Format::sanitize($vars['description']);
         $this->notes = Format::sanitize($vars['notes']);
 
@@ -223,26 +257,72 @@ class Category extends VerySimpleModel {
 
     /* ------------------> Static methods <--------------------- */
 
-    static function findIdByName($name) {
+    static function findIdByName($name, $pid=null) {
         $row = self::objects()->filter(array(
-            'name'=>$name
+            'name'=>$name,
+            'category_pid'  => $pid ?: null
         ))->values_flat('category_id')->first();
 
         return ($row) ? $row[0] : null;
     }
 
-    static function findByName($name) {
+    static function findByName($name, $pid=null) {
         return self::objects()->filter(array(
-            'name'=>$name
+            'name'=>$name,
+            'category_pid'  => $pid ?: null
         ))->one();
     }
 
+    static function getNameById($id) {
+        $names = static::getCategories();
+        return $names[$id] ?: '';
+    }
+
     static function getFeatured() {
         return self::objects()->filter(array(
             'ispublic'=>self::VISIBILITY_FEATURED
         ));
     }
 
+    static function getCategories($criteria=null, $localize=true) {
+        static $categories = null;
+
+        if (!isset($categories) || $criteria) {
+            $categories = array();
+            $query = self::objects();
+            $query->order_by('name')
+                ->values('category_id', 'category_pid', 'name', 'parent');
+
+            foreach ($query as $row)
+                $categories[$row['category_id']] = $row;
+
+            // Resolve parent names
+            $names = array();
+            foreach ($categories as $id=>$info) {
+                $name = $info['name'];
+                $loop = array($id=>true);
+                $parent = false;
+                while ($info['category_pid'] && ($info = $categories[$info['category_pid']])) {
+                    $name = sprintf('%s / %s', $info['name'], $name);
+                    if (isset($loop[$info['category_pid']]))
+                        break;
+                    $loop[$info['category_pid']] = true;
+                    $parent = $info;
+                }
+                // TODO: localize category names
+                $names[$id] = $name;
+            }
+            asort($names);
+
+            if ($criteria)
+                return $names;
+
+            $categories = $names;
+        }
+
+        return $categories;
+    }
+
     static function create($vars=false) {
         $category = new static($vars);
         $category->created = SqlFunction::NOW();
diff --git a/include/client/faq-category.inc.php b/include/client/faq-category.inc.php
index 3ce0b7230..2eb0edea6 100644
--- a/include/client/faq-category.inc.php
+++ b/include/client/faq-category.inc.php
@@ -1,14 +1,26 @@
 <?php
 if(!defined('OSTCLIENTINC') || !$category || !$category->isPublic()) die('Access Denied');
 ?>
-
 <div class="row">
 <div class="span8">
-    <h1><?php echo __('Frequently Asked Questions');?></h1>
-    <h2><strong><?php echo $category->getLocalName() ?></strong></h2>
+    <h1><?php echo $category->getFullName(); ?></h1>
 <p>
 <?php echo Format::safe_html($category->getLocalDescriptionWithImages()); ?>
 </p>
+<?php
+
+if (($subs=$category->getSubCategories(array('public' => true)))) {
+    echo '<div>';
+    foreach ($subs as $c) {
+        echo sprintf('<div><i class="icon-folder-open-alt"></i>
+                <a href="faq.php?cid=%d">%s (%d)</a></div>',
+                $c->getId(),
+                $c->getLocalName(),
+                $c->getNumFAQs()
+                );
+    }
+    echo '</div>';
+} ?>
 <hr>
 <?php
 $faqs = FAQ::objects()
@@ -22,7 +34,7 @@ $faqs = FAQ::objects()
 
 if ($faqs->exists(true)) {
     echo '
-         <h2>'.__('Further Articles').'</h2>
+         <h2>'.__('Frequently Asked Questions').'</h2>
          <div id="faq">
             <ol>';
 foreach ($faqs as $F) {
@@ -33,7 +45,7 @@ foreach ($faqs as $F) {
     }
     echo '  </ol>
          </div>';
-}else {
+} elseif (!$category->children) {
     echo '<strong>'.__('This category does not have any FAQs.').' <a href="index.php">'.__('Back To Index').'</a></strong>';
 }
 ?>
diff --git a/include/client/faq.inc.php b/include/client/faq.inc.php
index c90431562..e953c5475 100644
--- a/include/client/faq.inc.php
+++ b/include/client/faq.inc.php
@@ -7,10 +7,11 @@ $category=$faq->getCategory();
 <div class="row">
 <div class="span8">
 
-<h1><?php echo __('Frequently Asked Questions');?></h1>
-<div id="breadcrumbs">
+<h1><?php echo __('Frequently Asked Question');?></h1>
+<div id="breadcrumbs" style="padding-top:2px;">
     <a href="index.php"><?php echo __('All Categories');?></a>
-    &raquo; <a href="faq.php?cid=<?php echo $category->getId(); ?>"><?php echo $category->getName(); ?></a>
+    &raquo; <a href="faq.php?cid=<?php echo $category->getId(); ?>"><?php
+    echo $category->getFullName(); ?></a>
 </div>
 
 <div class="faq-content">
diff --git a/include/client/kb-categories.inc.php b/include/client/kb-categories.inc.php
index c4df171c5..a9a9f5139 100644
--- a/include/client/kb-categories.inc.php
+++ b/include/client/kb-categories.inc.php
@@ -4,23 +4,57 @@
     $categories = Category::objects()
         ->exclude(Q::any(array(
             'ispublic'=>Category::VISIBILITY_PRIVATE,
-            'faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE,
+            Q::all(array(
+                    'faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE,
+                    'children__ispublic' => Category::VISIBILITY_PRIVATE,
+                    'children__faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE,
+                    ))
         )))
-        ->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs')))
-        ->filter(array('faq_count__gt'=>0));
+        //->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs__ispublished')));
+        ->annotate(array('faq_count' => SqlAggregate::COUNT(
+                        SqlCase::N()
+                        ->when(array(
+                                'faqs__ispublished__gt'=> FAQ::VISIBILITY_PRIVATE), 1)
+                        ->otherwise(null)
+        )));
+
+       // ->filter(array('faq_count__gt' => 0));
     if ($categories->exists(true)) { ?>
         <div><?php echo __('Click on the category to browse FAQs.'); ?></div>
         <ul id="kb">
 <?php
-        foreach ($categories as $C) { ?>
+        foreach ($categories as $C) {
+            // Don't show subcategories with parents.
+            if (($p=$C->parent)
+                    && ($categories->findFirst(array(
+                                'category_id' => $p->getId()))))
+                continue;
+
+            ?>
             <li><i></i>
             <div style="margin-left:45px">
-            <h4><?php echo sprintf('<a href="faq.php?cid=%d">%s (%d)</a>',
-                $C->getId(), Format::htmlchars($C->getLocalName()), $C->faq_count); ?></h4>
+            <h4><?php echo sprintf('<a href="faq.php?cid=%d">%s %s</a>',
+                $C->getId(), Format::htmlchars($C->getFullName()),
+                $C->faq_count ? "({$C->faq_count})": ''
+                ); ?></h4>
             <div class="faded" style="margin:10px 0">
                 <?php echo Format::safe_html($C->getLocalDescriptionWithImages()); ?>
             </div>
-<?php       foreach ($C->faqs
+<?php
+            if (($subs=$C->getPublicSubCategories())) {
+                echo '<p/><div style="padding-bottom:15px;">';
+                foreach ($subs as $c) {
+                    echo sprintf('<div><i class="icon-folder-open"></i>
+                            <a href="faq.php?cid=%d">%s (%d)</a></div>',
+                            $c->getId(),
+                            $c->getLocalName(),
+                            $c->getNumFAQs()
+                            );
+                }
+                echo '</div>';
+            }
+
+            foreach ($C->faqs
                     ->exclude(array('ispublished'=>FAQ::VISIBILITY_PRIVATE))
                     ->limit(5) as $F) { ?>
                 <div class="popular-faq"><i class="icon-file-alt"></i>
diff --git a/include/staff/categories.inc.php b/include/staff/categories.inc.php
index 91e2419e7..cbfa58402 100644
--- a/include/staff/categories.inc.php
+++ b/include/staff/categories.inc.php
@@ -95,7 +95,7 @@ $pageNav->paginate($categories);
                             <?php echo $sel?'checked="checked"':''; ?>>
                 </td>
                 <td><a class="truncate" style="width:500px" href="categories.php?id=<?php echo $C->getId(); ?>"><?php
-                    echo $C->getLocalName(); ?></a></td>
+                    echo Category::getNamebyId($C->getId()); ?></a></td>
                 <td><?php echo $C->getVisibilityDescription(); ?></td>
                 <td style="text-align:right;padding-right:25px;"><?php echo $faqs; ?></td>
                 <td>&nbsp;<?php echo Format::datetime($C->updated); ?></td>
diff --git a/include/staff/category.inc.php b/include/staff/category.inc.php
index 4eac20421..20392a067 100644
--- a/include/staff/category.inc.php
+++ b/include/staff/category.inc.php
@@ -107,6 +107,24 @@ if (count($langs) > 1) { ?>
       ?>" id="lang-<?php echo $tag; ?>"
       <?php if ($i['direction'] == 'rtl') echo 'dir="rtl" class="rtl"'; ?>
     >
+    <div style="padding-bottom:8px;">
+        <b><?php echo __('Parent');?></b>:
+        <div class="faded"><?php echo __('Parent Category');?></div>
+    </div>
+    <div style="padding-bottom:8px;">
+        <select name="pid">
+            <option value="">&mdash; <?php echo __('Top-Level Category'); ?> &mdash;</option>
+            <?php
+            foreach (Category::getCategories() as $id=>$name) {
+                if ($info['id'] && $id == $info['id'])
+                    continue; ?>
+                <option value="<?php echo $id; ?>" <?php
+                    if ($info['category_pid'] == $id) echo 'selected="selected"';
+                    ?>><?php echo $name; ?></option>
+            <?php
+            } ?>
+        </select>
+    </div>
     <div style="padding-bottom:8px;">
         <b><?php echo __('Category Name');?></b>:
         <span class="error">*</span>
diff --git a/include/staff/faq-categories.inc.php b/include/staff/faq-categories.inc.php
index 4c4848227..2de840b58 100644
--- a/include/staff/faq-categories.inc.php
+++ b/include/staff/faq-categories.inc.php
@@ -55,7 +55,7 @@ foreach ($categories as $C) {
                 <i class="icon-fixed-width <?php
                 if ($active) echo 'icon-hand-right'; ?>"></i>
                 <?php echo sprintf('%s (%d)',
-                    Format::htmlchars($C->getLocalName()),
+                    Format::htmlchars($C->getFullName()),
                     $C->faq_count); ?></a>
         </li> <?php
 } ?>
@@ -140,7 +140,9 @@ if($_REQUEST['q'] || $_REQUEST['cid'] || $_REQUEST['topicId']) { //Search.
     }
 } else { //Category Listing.
     $categories = Category::objects()
-        ->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs')));
+        ->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs')))
+        ->filter(array('category_pid__isnull' => true));
+
 
     if (count($categories)) {
         $categories->sort(function($a) { return $a->getLocalName(); });
@@ -150,11 +152,25 @@ if($_REQUEST['q'] || $_REQUEST['cid'] || $_REQUEST['topicId']) { //Search.
             echo sprintf('
                 <li>
                     <h4><a class="truncate" style="max-width:600px" href="kb.php?cid=%d">%s (%d)</a> - <span>%s</span></h4>
-                    %s
-                </li>',$C->getId(),$C->getLocalName(),$C->faq_count,
+                    %s ',
+                $C->getId(),$C->getLocalName(),$C->faq_count,
                 $C->getVisibilityDescription(),
                 Format::safe_html($C->getLocalDescriptionWithImages())
-            );
+                );
+                if ($C->children) {
+                    echo '<p/><div>';
+                    foreach ($C->children as $c) {
+                        echo sprintf('<div><i class="icon-folder-open-alt"></i>
+                                <a href="kb.php?cid=%d">%s (%d)</a> - <span>%s</span></div>',
+                                $c->getId(),
+                                $c->getLocalName(),
+                                $c->getNumFAQs(),
+                                $c->getVisibilityDescription()
+                                );
+                    }
+                    echo '</div>';
+                }
+            echo '</li>';
         }
         echo '</ul>';
     } else {
diff --git a/include/staff/faq-category.inc.php b/include/staff/faq-category.inc.php
index 0dcff184f..36bedc6d9 100644
--- a/include/staff/faq-category.inc.php
+++ b/include/staff/faq-category.inc.php
@@ -36,13 +36,27 @@ echo sprintf('<div class="pull-right flush-right">
 </div>
 <div class="faq-category">
     <div style="margin-bottom:10px;">
-        <div class="faq-title pull-left"><?php echo $category->getName() ?></div>
+        <div class="faq-title pull-left"><?php echo $category->getFullName() ?></div>
         <div class="faq-status inline">(<?php echo $category->isPublic()?__('Public'):__('Internal'); ?>)</div>
         <div class="clear"><time class="faq"> <?php echo __('Last Updated').' '. Format::daydatetime($category->getUpdateDate()); ?></time></div>
     </div>
     <div class="cat-desc has_bottom_border">
-    <?php echo Format::display($category->getDescription()); ?>
-</div>
+    <?php echo Format::display($category->getDescription());
+    if ($category->children) {
+        echo '<p/><div>';
+        foreach ($category->children as $c) {
+            echo sprintf('<div><i class="icon-folder-open-alt"></i>
+                    <a href="kb.php?cid=%d">%s (%d)</a> - <span>%s</span></div>',
+                    $c->getId(),
+                    $c->getLocalName(),
+                    $c->getNumFAQs(),
+                    $c->getVisibilityDescription()
+                    );
+        }
+        echo '</div>';
+    }
+    ?>
+    </div>
 <?php
 
 
@@ -61,7 +75,7 @@ if ($faqs->exists(true)) {
     }
     echo '  </ol>
          </div>';
-}else {
+} elseif (!$category->children) {
     echo '<strong>'.__('Category does not have FAQs').'</strong>';
 }
 ?>
diff --git a/include/staff/faq-view.inc.php b/include/staff/faq-view.inc.php
index 9f46a4994..a5dc4e560 100644
--- a/include/staff/faq-view.inc.php
+++ b/include/staff/faq-view.inc.php
@@ -29,7 +29,8 @@ if ($thisstaff->hasPerm(FAQ::PERM_MANAGE)) { ?>
 
 <div id="breadcrumbs">
     <a href="kb.php"><?php echo __('All Categories');?></a>
-    &raquo; <a href="kb.php?cid=<?php echo $category->getId(); ?>"><?php echo $category->getName(); ?></a>
+    &raquo; <a href="kb.php?cid=<?php echo $category->getId(); ?>"><?php
+    echo $category->getFullName(); ?></a>
     <span class="faded">(<?php echo $category->isPublic()?__('Public'):__('Internal'); ?>)</span>
 </div>
 
diff --git a/include/staff/faq.inc.php b/include/staff/faq.inc.php
index 4579deb8d..830dc4645 100644
--- a/include/staff/faq.inc.php
+++ b/include/staff/faq.inc.php
@@ -63,7 +63,7 @@ $qstr = Http::build_query($qs);
         <option value="<?php echo $C->getId(); ?>" <?php
             if ($C->getId() == $info['category_id']) echo 'selected="selected"';
             ?>><?php echo sprintf('%s (%s)',
-                $C->getName(),
+                Category::getNameById($C->getId()),
                 $C->isPublic() ? __('Public') : __('Private')
             ); ?></option>
 <?php } ?>
-- 
GitLab