diff --git a/core/components/com_kb/models/article.php b/core/components/com_kb/models/article.php index 050a9ee3b3d..f0a4a8b30c8 100644 --- a/core/components/com_kb/models/article.php +++ b/core/components/com_kb/models/article.php @@ -379,6 +379,86 @@ public function link($type='') return $link; } + /** + * A plain-text excerpt of the article around a search term + * + * The excerpt is HTML-escaped with each match wrapped in . When + * the whole phrase does not occur, the earliest of its words is used. + * + * @param string $needle Search term + * @param integer $radius Characters of context on either side + * @return string HTML, or '' when the term is empty or does not occur + */ + public function snippet($needle, $radius = 160) + { + $needle = trim(preg_replace('/\s+/u', ' ', (string) $needle)); + + if ($needle === '') + { + return ''; + } + + // Field wrappers hold editor markup, not prose + $text = preg_replace('#.*?#is', ' ', stripslashes((string) $this->get('fulltxt'))); + $text = html_entity_decode(strip_tags($text), ENT_QUOTES, 'UTF-8'); + $text = trim(preg_replace('/\s+/u', ' ', $text)); + + if ($text === '') + { + return ''; + } + + // The phrase, else whichever of its words comes first + $terms = array($needle); + $position = mb_stripos($text, $needle, 0, 'UTF-8'); + + if ($position === false) + { + $terms = array(); + + foreach (array_unique(preg_split('/ /', $needle)) as $word) + { + $at = mb_stripos($text, $word, 0, 'UTF-8'); + + if ($at !== false) + { + $terms[] = $word; + $position = ($position === false) ? $at : min($position, $at); + } + } + + if ($position === false) + { + return ''; + } + } + + $start = max(0, $position - $radius); + $length = $radius * 2 + mb_strlen($needle, 'UTF-8'); + $excerpt = mb_substr($text, $start, $length, 'UTF-8'); + + // Cut on word boundaries, then mark the ends that were cut + if ($start > 0 && ($space = mb_strpos($excerpt, ' ', 0, 'UTF-8')) !== false) + { + $excerpt = mb_substr($excerpt, $space + 1, null, 'UTF-8'); + } + if ($start + $length < mb_strlen($text, 'UTF-8') && ($space = mb_strrpos($excerpt, ' ', 0, 'UTF-8')) !== false) + { + $excerpt = mb_substr($excerpt, 0, $space, 'UTF-8'); + } + + $excerpt = htmlspecialchars($excerpt, ENT_QUOTES, 'UTF-8'); + + $patterns = array_map(function ($term) + { + return preg_quote(htmlspecialchars($term, ENT_QUOTES, 'UTF-8'), '/'); + }, $terms); + + $excerpt = preg_replace('/(' . implode('|', $patterns) . ')/iu', '$1', $excerpt); + + return ($start > 0 ? '… ' : '') . $excerpt . ($start + $length < mb_strlen($text, 'UTF-8') ? ' …' : ''); + } + /** * Get a list of votes * diff --git a/core/components/com_kb/models/category.php b/core/components/com_kb/models/category.php index 2fe3482aed2..f399dfa01e6 100644 --- a/core/components/com_kb/models/category.php +++ b/core/components/com_kb/models/category.php @@ -135,7 +135,9 @@ public function parent() public function link($type='') { $link = $this->_base; - $link .= '§ion=' . $this->get('path'); + // A category built in code (the "all" pseudo-category) has an alias + // but no stored path; without a section the router picks the wrong task + $link .= '§ion=' . ($this->get('path') ?: $this->get('alias')); // If it doesn't exist or isn't published switch (strtolower($type)) diff --git a/core/components/com_kb/site/assets/css/kb.css b/core/components/com_kb/site/assets/css/kb.css index f6b2b0d05ef..fd9cf10b1c3 100644 --- a/core/components/com_kb/site/assets/css/kb.css +++ b/core/components/com_kb/site/assets/css/kb.css @@ -70,3 +70,22 @@ line-height: 0; clear: left; } +/* Search results */ + .com_kb .entry-snippet { + display: block; + margin: 0.3em 0 0.5em 0; + color: #666; + font-size: 0.95em; + line-height: 1.4; + } + .com_kb .entry-snippet mark { + padding: 0 0.15em; + background-color: #fff3a0; + color: inherit; + } + .com_kb .search-results-for { + margin: 0 0 0.5em 0; + } + .com_kb .search-results-for a { + margin-left: 0.5em; + } diff --git a/core/components/com_kb/site/controllers/articles.php b/core/components/com_kb/site/controllers/articles.php index c89e9699e66..b7cc07b8667 100644 --- a/core/components/com_kb/site/controllers/articles.php +++ b/core/components/com_kb/site/controllers/articles.php @@ -48,12 +48,36 @@ public function execute() */ public function displayTask() { + // The same contract as categoryTask(), so the two layouts can share + // the category navigation and search form $this->view ->set('archive', $this->archive) + ->set('category', $this->allCategory()) + ->set('catid', 0) + ->set('filters', array( + 'sort' => '', + 'search' => '' + )) ->setLayout('display') ->display(); } + /** + * The pseudo-category that stands for every article + * + * @return Category + */ + private function allCategory() + { + $category = new Category; + $category->set('alias', 'all'); + $category->set('title', Lang::txt('COM_KB_ALL_ARTICLES')); + $category->set('id', 0); + $category->set('published', 1); + + return $category; + } + /** * Displays a list of articles for a given category * @@ -74,11 +98,7 @@ public function categoryTask() if ($categoryAlias == 'all') { - $category = new Category; - $category->set('alias', 'all'); - $category->set('title', Lang::txt('COM_KB_ALL_ARTICLES')); - $category->set('id', 0); - $category->set('published', 1); + $category = $this->allCategory(); } else { diff --git a/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini b/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini index ba5fd0bc4c3..7c19c9bcf9c 100644 --- a/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini +++ b/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini @@ -60,6 +60,8 @@ COM_KB_SEARCH="Search" COM_KB_SEARCH_LEGEND="Search for articles" COM_KB_SEARCH_LABEL="Enter keyword or phrase" COM_KB_SEARCH_PLACEHOLDER="Enter keyword or phrase" +COM_KB_SEARCH_RESULTS_FOR="Results for "%s"" +COM_KB_SEARCH_CLEAR="Clear search" ; Errors COM_KB_USER_ALREADY_VOTED="User has already voted." diff --git a/core/components/com_kb/site/views/articles/tmpl/_categories.php b/core/components/com_kb/site/views/articles/tmpl/_categories.php new file mode 100644 index 00000000000..6680b6f65b2 --- /dev/null +++ b/core/components/com_kb/site/views/articles/tmpl/_categories.php @@ -0,0 +1,63 @@ + 1, 'access' => User::getAuthorisedViewLevels()); +$categories = $this->archive->categories($filters); + +$carry = ''; +if (!empty($this->filters['sort'])) +{ + $carry .= '&sort=' . $this->escape($this->filters['sort']); +} +if (!empty($this->filters['search'])) +{ + $carry .= '&search=' . urlencode($this->filters['search']); +} +?> + diff --git a/core/components/com_kb/site/views/articles/tmpl/_help.php b/core/components/com_kb/site/views/articles/tmpl/_help.php new file mode 100644 index 00000000000..6ae1290239f --- /dev/null +++ b/core/components/com_kb/site/views/articles/tmpl/_help.php @@ -0,0 +1,37 @@ + + +
+

+

+ ' . Lang::txt('COM_ANSWERS') . ''); ?> +

+
+ + +
+

+

+ +

+
+ + +
+

+

+ +

+
+ diff --git a/core/components/com_kb/site/views/articles/tmpl/_search.php b/core/components/com_kb/site/views/articles/tmpl/_search.php new file mode 100644 index 00000000000..f7729e14477 --- /dev/null +++ b/core/components/com_kb/site/views/articles/tmpl/_search.php @@ -0,0 +1,27 @@ +filters['search']) ? $this->filters['search'] : ''; +$sort = isset($this->filters['sort']) ? $this->filters['sort'] : ''; +?> + diff --git a/core/components/com_kb/site/views/articles/tmpl/category.php b/core/components/com_kb/site/views/articles/tmpl/category.php index 32b3c1b488b..398ac1c8a8f 100644 --- a/core/components/com_kb/site/views/articles/tmpl/category.php +++ b/core/components/com_kb/site/views/articles/tmpl/category.php @@ -29,6 +29,12 @@

+ view('_search') + ->set('option', $this->option) + ->set('filters', $this->filters) + ->display(); + ?>

@@ -41,184 +47,159 @@

getError(); ?>

-
+ filters['search']; + $carry = $search ? '&search=' . urlencode($search) : ''; + ?> +
+ +

+ escape($search)); ?> + +

+ + -
- -
- - - -
-
+ 1, 'access' => User::getAuthorisedViewLevels()); -
- + $categories = $this->archive->categories($filters); - 1, 'access' => User::getAuthorisedViewLevels()); + if (!$this->category->get('id')) + { + $articles = $this->archive->articles(); + } + else + { + $articles = $this->category->articles(); + } - $categories = $this->archive->categories($filters); + $articles->whereEquals('state', 1) + ->whereIn('access', User::getAuthorisedViewLevels()); - if (!$this->category->get('id')) - { - $articles = $this->archive->articles(); - } - else - { - $articles = $this->category->articles(); - } + if (isset($this->filters['search']) && $this->filters['search']) + { + $articles->whereLike('title', $this->filters['search'], 1) + ->orWhereLike('fulltxt', $this->filters['search'], 1) + ->resetDepth(); + } + if ($this->filters['sort'] == 'popularity') + { + $articles->order('helpful', 'desc'); + } + else + { + $articles->order('modified', 'desc') + ->order('created', 'desc'); + } - $articles->whereEquals('state', 1) - ->whereIn('access', User::getAuthorisedViewLevels()); + $articles = $articles->paginated(); - if (isset($this->filters['search']) && $this->filters['search']) - { - $articles->whereLike('title', $this->filters['search'], 1) - ->orWhereLike('fulltxt', $this->filters['search'], 1) - ->resetDepth(); - } - if ($this->filters['sort'] == 'popularity') - { - $articles->order('helpful', 'desc'); - } - else + if ($articles->count() > 0) { ?> + + + + + + + + + + + order('modified', 'desc') - ->order('created', 'desc'); - } - - $articles = $articles->paginated(); - - if ($articles->count() > 0) { ?> -
- - - - - - - - - - category->get('id')) { - if (!$this->category->get('id')) + foreach ($categories as $cat) { - foreach ($categories as $cat) + if ($cat->get('id') == $row->get('category')) { - if ($cat->get('id') == $row->get('category')) - { - $row->set('ctitle', $cat->get('title')); - $row->set('calias', $cat->get('path')); - break; - } + $row->set('ctitle', $cat->get('title')); + $row->set('calias', $cat->get('path')); + break; } } - else - { - $row->set('calias', $this->category->get('path')); - $row->set('ctitle', $this->category->get('title')); - } - ?> - - - - + + + - - - -
- get('id'); ?> - - escape(stripslashes($row->get('title',''))); ?>
- - catid <= 0) { echo Lang::txt('COM_KB_IN_CATEGORY', $this->escape(stripslashes($row->get('ctitle','') ?? ''))); } ?> - - - modified('time'); ?> - - - -
- view('_vote') - ->set('option', $this->option) - ->set('item', $row) - ->set('type', 'entry') - ->set('vote', '') - ->set('id', ''); - if (!User::isGuest()) + } + else + { + $row->set('calias', $this->category->get('path')); + $row->set('ctitle', $this->category->get('title')); + } + ?> +
+ get('id'); ?> + + escape(stripslashes($row->get('title',''))); ?>
+ snippet($search))) { ?> + + + + catid <= 0) { echo Lang::txt('COM_KB_IN_CATEGORY', $this->escape(stripslashes($row->get('ctitle','') ?? ''))); } ?> + + + modified('time'); ?> + + + +
+ view('_vote') + ->set('option', $this->option) + ->set('item', $row) + ->set('type', 'entry') + ->set('vote', '') + ->set('id', ''); + if (!User::isGuest()) + { + if ($row->get('user_id') == User::get('id')) { - if ($row->get('user_id') == User::get('id')) - { - $view->set('vote', $row->get('vote')); - $view->set('id', $row->get('id')); - } + $view->set('vote', $row->get('vote')); + $view->set('id', $row->get('id')); } - $view->display(); - ?> -
+ } + $view->display(); + ?> + + - pagination - ->setAdditionalUrlParam('search', $this->filters['search']) - ->setAdditionalUrlParam('sort', $this->filters['sort']); - ?> -
- + + + + pagination + ->setAdditionalUrlParam('search', $this->filters['search']) + ->setAdditionalUrlParam('sort', $this->filters['sort']); + ?> +
diff --git a/core/components/com_kb/site/views/articles/tmpl/display.php b/core/components/com_kb/site/views/articles/tmpl/display.php index 7cec6d7fe7a..5ccb4d316d5 100644 --- a/core/components/com_kb/site/views/articles/tmpl/display.php +++ b/core/components/com_kb/site/views/articles/tmpl/display.php @@ -23,177 +23,161 @@ ?>

+ +
+ view('_search') + ->set('option', $this->option) + ->set('filters', $this->filters) + ->display(); + ?> +
-
-
- -
- - - -
-
- -
-
-

-
-
-

- - » - -

- archive->articles() - ->whereIn('access', User::getAuthorisedViewLevels()) - ->whereEquals('state', 1) - ->order('helpful', 'desc') // (a.helpful - a.nothelpful) - ->limit(5) - ->rows(); - if (count($popular) > 0) { ?> - - -

+
+
+

+
+
+

+ + » + +

+ archive->articles() + ->whereIn('access', User::getAuthorisedViewLevels()) + ->whereEquals('state', 1) + ->order('helpful', 'desc') // (a.helpful - a.nothelpful) + ->limit(5) + ->rows(); + if (count($popular) > 0) { ?> +
-
-

- - » - -

- archive->articles() - ->whereIn('access', User::getAuthorisedViewLevels()) - ->whereEquals('state', 1) - ->order('modified', 'desc') - ->order('created', 'desc') - ->limit(5) - ->rows(); - if (count($recent) > 0) { ?> - - -

+ + +

+ +
+
+

+ + » + +

+ archive->articles() + ->whereIn('access', User::getAuthorisedViewLevels()) + ->whereEquals('state', 1) + ->order('modified', 'desc') + ->order('created', 'desc') + ->limit(5) + ->rows(); + if (count($recent) > 0) { ?> +
-
+ + +

+ +
+
-

-
- +
+ archive->categories(array('state' => 1, 'access' => User::getAuthorisedViewLevels())); + $categories = $this->archive->categories(array('state' => 1, 'access' => User::getAuthorisedViewLevels())); - foreach ($categories as $row) - { - $articles = $row->articles() - ->whereEquals('state', 1) - ->whereIn('access', User::getAuthorisedViewLevels()) - ->order('modified', 'desc') - ->order('created', 'desc') - ->limit(3) - ->rows(); + foreach ($categories as $row) + { + $articles = $row->articles() + ->whereEquals('state', 1) + ->whereIn('access', User::getAuthorisedViewLevels()) + ->order('modified', 'desc') + ->order('created', 'desc') + ->limit(3) + ->rows(); - if ($articles->count() <= 0) - { - continue; - } + if ($articles->count() <= 0) + { + continue; + } - $i++; - switch ($i) - { - case 1: - $cls = ''; - break; - case 2: - $cls = ' omega'; - break; - } - ?> -
-

- - escape(stripslashes($row->get('title'))); ?> (get('articles', 0); ?>) » - -

- count() > 0) { ?> - - -

+ $i++; + switch ($i) + { + case 1: + $cls = ''; + break; + case 2: + $cls = ' omega'; + break; + } + ?> + - = 2) ? '
' : ''; + + +

+ +
+ = 2) ? '
' : ''; - if ($i >= 2) - { - $i = 0; - } + if ($i >= 2) + { + $i = 0; } - ?> -
-
-
- + } + ?> +
+
+
diff --git a/core/components/com_kb/tests/models/ArticleSnippetTest.php b/core/components/com_kb/tests/models/ArticleSnippetTest.php new file mode 100644 index 00000000000..a92d62bfedf --- /dev/null +++ b/core/components/com_kb/tests/models/ArticleSnippetTest.php @@ -0,0 +1,218 @@ +container = $container; + } + public function get($id) + { + return $this->container->get($id); + } + public function has($id) + { + return $this->container->has($id); + } + }; + }; + + $this->previousApp = Facade::getApplication(); + Facade::setApplication($app); + } + + /** + * @return void + */ + protected function tearDown(): void + { + Facade::setApplication($this->previousApp); + + parent::tearDown(); + } + + /** + * An article with the given body + * + * @param string $fulltxt + * @return Article + */ + private function article(string $fulltxt): Article + { + $article = new Article(); + $article->set('fulltxt', $fulltxt); + + return $article; + } + + /** + * The phrase is found, escaped, and marked + * + * @return void + */ + public function testMarksThePhrase() + { + $article = $this->article('

Upload your data set & describe it.

'); + + $this->assertSame('Upload your data set & describe it.', $article->snippet('data set')); + } + + /** + * Matching ignores case; the original casing is kept + * + * @return void + */ + public function testMatchesCaseInsensitively() + { + $article = $this->article('

Your Dataset lives here.

'); + + $this->assertSame('Your Dataset lives here.', $article->snippet('dataset')); + } + + /** + * When the phrase is absent, its words are marked instead + * + * @return void + */ + public function testFallsBackToWords() + { + $article = $this->article('

Publish the files, then cite the dataset.

'); + + $this->assertSame( + 'Publish the files, then cite the dataset.', + $article->snippet('dataset files') + ); + } + + /** + * Field wrappers and markup are stripped before searching + * + * @return void + */ + public function testStripsFieldWrappersAndTags() + { + $article = $this->article('summary data

Body text about data.

'); + + $this->assertSame('Body text about data.', $article->snippet('data')); + } + + /** + * A long body is cut to the context either side, on word boundaries + * + * @return void + */ + public function testCutsLongBodiesAroundTheMatch() + { + $article = $this->article(str_repeat('before ', 60) . 'needle here ' . str_repeat('after ', 60)); + + $snippet = $article->snippet('needle', 30); + + $this->assertStringStartsWith('… before', $snippet); + $this->assertStringEndsWith('after …', $snippet); + $this->assertStringContainsString('needle here', $snippet); + $this->assertLessThan(120, strlen(strip_tags($snippet))); + } + + /** + * Nothing to show when the term is empty or absent + * + * @return void + */ + public function testEmptyWhenAbsent() + { + $article = $this->article('

Nothing relevant.

'); + + $this->assertSame('', $article->snippet('')); + $this->assertSame('', $article->snippet(' ')); + $this->assertSame('', $article->snippet('zebra')); + $this->assertSame('', $this->article('')->snippet('zebra')); + } + + /** + * Markup in the term cannot escape into the page + * + * @return void + */ + public function testTermIsEscaped() + { + $article = $this->article('

See bold text.

'); + + $this->assertSame('See <b>bold text.', $this->article('See <b>bold text.')->snippet('bold')); + $this->assertStringNotContainsString('snippet('