diff --git a/app/Controllers/Routing.php b/app/Controllers/Routing.php index 1742c40a..a81e5bbc 100644 --- a/app/Controllers/Routing.php +++ b/app/Controllers/Routing.php @@ -66,7 +66,8 @@ class Routing } // поиск if ($user->g_search == '1') { - $r->add('GET', '/search', 'Search:view', 'Search'); + $r->add(['GET','POST'], '/search[/{advanced:advanced}]', 'Search:view', 'Search'); + $r->add('GET', '/search/{action:last|unanswered}[/{page:[1-9]\d*}]', 'Search:action', 'SearchAction'); } // юзеры if ($user->g_view_users == '1') { diff --git a/app/Models/Config/Load.php b/app/Models/Config/Load.php index 883bedeb..efa4ddbe 100644 --- a/app/Models/Config/Load.php +++ b/app/Models/Config/Load.php @@ -3,6 +3,7 @@ namespace ForkBB\Models\Config; use ForkBB\Models\Method; +use PDO; class Load extends Method { @@ -14,7 +15,7 @@ class Load extends Method */ public function load() { - $config = $this->c->DB->query('SELECT conf_name, conf_value FROM ::config')->fetchAll(\PDO::FETCH_KEY_PAIR); + $config = $this->c->DB->query('SELECT conf_name, conf_value FROM ::config')->fetchAll(PDO::FETCH_KEY_PAIR); $this->model->setAttrs($config); $this->c->Cache->set('config', $config); return $this->model; diff --git a/app/Models/Forum/Model.php b/app/Models/Forum/Model.php index 52b815ca..64355ab2 100644 --- a/app/Models/Forum/Model.php +++ b/app/Models/Forum/Model.php @@ -186,7 +186,7 @@ class Model extends DataModel throw new RuntimeException('The model does not have the required data'); } - return $this->num_topics === 0 ? 1 : (int) ceil($this->num_topics / $this->c->user->disp_topics); + return (int) ceil(($this->num_topics ?: 1) / $this->c->user->disp_topics); } /** @@ -249,8 +249,8 @@ class Model extends DataModel ORDER BY sticky DESC, {$sortBy}, id DESC LIMIT ?i:offset, ?i:rows"; - $ids = $this->c->DB->query($sql, $vars)->fetchAll(PDO::FETCH_COLUMN); + $this->idsList = $this->c->DB->query($sql, $vars)->fetchAll(PDO::FETCH_COLUMN); - return empty($ids) ? [] : $this->c->topics->view($ids); + return empty($this->idsList) ? [] : $this->c->topics->view($this); } } diff --git a/app/Models/Pages/CrumbTrait.php b/app/Models/Pages/CrumbTrait.php index 83188710..f9acdff6 100644 --- a/app/Models/Pages/CrumbTrait.php +++ b/app/Models/Pages/CrumbTrait.php @@ -3,6 +3,7 @@ namespace ForkBB\Models\Pages; use ForkBB\Models\Model; +use ForkBB\Models\Search\Model as Search; trait CrumbTrait { @@ -20,8 +21,18 @@ trait CrumbTrait $active = true; foreach ($args as $arg) { - // Раздел или топик - if ($arg instanceof Model) { + // поиск + if ($arg instanceof Search) { + if ($arg->page > 1) { + $this->titles = $arg->name . ' ' . \ForkBB\__('Page %s', $arg->page); + } else { + $this->titles = $arg->name; + } + $crumbs[] = [$arg->link, $arg->name, $active]; + $this->titles = \ForkBB\__('Search'); + $crumbs[] = [$this->c->Router->link('Search'), \ForkBB\__('Search'), null]; + // раздел или топик + } elseif ($arg instanceof Model) { while (null !== $arg->parent && $arg->link) { if (isset($arg->forum_name)) { $name = $arg->forum_name; @@ -37,13 +48,17 @@ trait CrumbTrait $this->titles = $name; } $crumbs[] = [$arg->link, $name, $active]; - $active = null; - $arg = $arg->parent; + $active = null; + $arg = $arg->parent; } - // Строка + // ссылка + } elseif (is_array($arg)) { + $this->titles = $arg[1]; + $crumbs[] = [$arg[0], $arg[1], $active]; + // строка } else { $this->titles = (string) $arg; - $crumbs[] = [null, (string) $arg, $active]; + $crumbs[] = [null, (string) $arg, $active]; } $active = null; } diff --git a/app/Models/Pages/Edit.php b/app/Models/Pages/Edit.php index 3732be3a..692041b3 100644 --- a/app/Models/Pages/Edit.php +++ b/app/Models/Pages/Edit.php @@ -14,10 +14,10 @@ class Edit extends Page /** * Редактирование сообщения - * + * * @param array $args * @param string $method - * + * * @return Page */ public function edit(array $args, $method) @@ -64,20 +64,22 @@ class Edit extends Page $this->formTitle = $editSubject ? \ForkBB\__('Edit topic') : \ForkBB\__('Edit post'); $this->crumbs = $this->crumbs($this->formTitle, $topic); $this->form = $this->messageForm($post, 'EditPost', $args, true, $editSubject); - + return $this; } /** * Сохранение сообщения - * + * * @param Post $post * @param Validator $v - * + * * @return Page */ protected function endEdit(Post $post, Validator $v) { + $this->c->DB->beginTransaction(); + $now = time(); $user = $this->c->user; $executive = $user->isAdmin || $user->isModerator($post); @@ -139,13 +141,17 @@ class Edit extends Page $topic->parent->calcStat(); } $this->c->forums->update($topic->parent); - - // антифлуд - if ($calcPost || $calcForum) { + + // антифлуд + if ($calcPost || $calcForum) { $user->last_post = $now; //???? $this->c->users->update($user); } - + + $this->c->search->index($post, 'edit'); + + $this->c->DB->commit(); + return $this->c->Redirect ->page('ViewPost', ['id' => $post->id]) ->message('Edit redirect'); diff --git a/app/Models/Pages/Forum.php b/app/Models/Pages/Forum.php index 43d1cc2e..5d2b7705 100644 --- a/app/Models/Pages/Forum.php +++ b/app/Models/Pages/Forum.php @@ -39,8 +39,7 @@ class Forum extends Page $this->nameTpl = 'forum'; $this->onlinePos = 'forum-' . $args['id']; $this->canonical = $this->c->Router->link('Forum', ['id' => $args['id'], 'name' => $forum->forum_name, 'page' => $forum->page]); - $this->forum = $forum; - $this->forums = $forum->subforums; + $this->model = $forum; $this->topics = $forum->pageData(); $this->crumbs = $this->crumbs($forum); diff --git a/app/Models/Pages/Post.php b/app/Models/Pages/Post.php index 893f64a6..d3aeb9ee 100644 --- a/app/Models/Pages/Post.php +++ b/app/Models/Pages/Post.php @@ -16,10 +16,10 @@ class Post extends Page /** * Создание новой темы - * + * * @param array $args * @param string $method - * + * * @return Page */ public function newTopic(array $args, $method) @@ -38,7 +38,7 @@ class Post extends Page if ($v->validation($_POST) && null === $v->preview) { return $this->endPost($forum, $v); } - + $this->fIswev = $v->getErrors(); $args['_vars'] = $v->getData(); //???? @@ -54,16 +54,16 @@ class Post extends Page $this->crumbs = $this->crumbs(\ForkBB\__('Post new topic'), $forum); $this->formTitle = \ForkBB\__('Post new topic'); $this->form = $this->messageForm($forum, 'NewTopic', $args, false, true); - + return $this; } /** * Подготовка данных для шаблона создания сообщения - * + * * @param array $args * @param string $method - * + * * @return Page */ public function newReply(array $args, $method) @@ -82,10 +82,10 @@ class Post extends Page if ($v->validation($_POST) && null === $v->preview) { return $this->endPost($topic, $v); } - + $this->fIswev = $v->getErrors(); $args['_vars'] = $v->getData(); //???? - + if (null !== $v->preview && ! $v->getErrors()) { $this->previewHtml = $this->c->Parser->parseMessage(null, (bool) $v->hide_smilies); } @@ -109,37 +109,39 @@ class Post extends Page $this->crumbs = $this->crumbs(\ForkBB\__('Post a reply'), $topic); $this->formTitle = \ForkBB\__('Post a reply'); $this->form = $this->messageForm($topic, 'NewReply', $args); - + return $this; } /** * Создание темы/сообщения - * + * * @param Model $model * @param Validator $v - * + * * @return Page */ protected function endPost(Model $model, Validator $v) { + $this->c->DB->beginTransaction(); + $now = time(); $user = $this->c->user; $username = $user->isGuest ? $v->username : $user->username; $merge = false; $executive = $user->isAdmin || $user->isModerator($model); - + // подготовка к объединению/сохранению сообщения if (null === $v->subject) { $createTopic = false; $forum = $model->parent; $topic = $model; - + if (! $user->isGuest && $topic->last_poster === $username) { if ($executive) { if ($v->merge_post) { $merge = true; - } + } } else { if ($this->c->config->o_merge_timeout > 0 // ???? стоит завязать на время редактирование сообщений? && $now - $topic->last_post < $this->c->config->o_merge_timeout @@ -165,7 +167,7 @@ class Post extends Page # $topic->poll_time = ; # $topic->poll_term = ; # $topic->poll_kol = ; - + $this->c->topics->insert($topic); } @@ -184,11 +186,11 @@ class Post extends Page $merge = false; } } - + // создание нового сообщения if (! $merge) { $post = $this->c->posts->create(); - + $post->poster = $username; $post->poster_id = $this->c->user->id; $post->poster_ip = $this->c->user->ip; @@ -224,7 +226,15 @@ class Post extends Page } $user->last_post = $now; $this->c->users->update($user); - + + if ($merge) { + $this->c->search->index($lastPost, 'merge'); + } else { + $this->c->search->index($post); + } + + $this->c->DB->commit(); + return $this->c->Redirect ->page('ViewPost', ['id' => $merge ? $lastPost->id : $post->id]) ->message('Post redirect'); diff --git a/app/Models/Pages/Search.php b/app/Models/Pages/Search.php new file mode 100644 index 00000000..39c00c9b --- /dev/null +++ b/app/Models/Pages/Search.php @@ -0,0 +1,288 @@ +c->Lang->load('search'); + + $v = null; + if ('POST' === $method) { + $v = $this->c->Validator->reset() + ->addValidators([ + 'check_query' => [$this, 'vCheckQuery'], + ])->addRules([ + 'token' => 'token:Search', + 'keywords' => 'required|string:trim|max:100|check_query', + 'author' => 'absent', + 'forums' => 'absent', + 'serch_in' => 'absent', + 'sort_by' => 'absent', + 'sort_dir' => 'absent', + 'show_as' => 'absent', + ])->addArguments([ + 'token' => $args, + ])->addAliases([ + 'keywords' => 'Keyword search', + 'author' => 'Author search', + 'forums' => 'Forum search', + 'serch_in' => 'Search in', + 'sort_by' => 'Sort by', + 'sort_dir' => 'Sort order', + 'show_as' => 'Show as', + ]); + + if (isset($args['advanced'])) { + $v->addRules([ + 'author' => 'string:trim|max:25', + 'forums' => 'integer', + 'serch_in' => 'required|integer|in:0,1,2', + 'sort_by' => 'required|integer|in:0,1,2,3', + 'sort_dir' => 'required|integer|in:0,1', + 'show_as' => 'required|integer|in:0,1', + ]); + } + + if ($v->validation($_POST)) { + $this->c->search->execute([ + 'keywords' => $v->keywords, + 'author' => (string) $v->author, + 'forums' => $v->forums, + 'serch_in' => ['all', 'posts', 'topics'][(int) $v->serch_in], + 'sort_by' => ['post', 'author', 'subject', 'forum'][(int) $v->sort_by], + 'sort_dir' => ['desc', 'asc'][(int) $v->sort_dir], + 'show_as' => ['posts', 'topics'][(int) $v->show_as], + ]); + } + + $this->fIswev = $v->getErrors(); + } + + $form = [ + 'action' => $this->c->Router->link('Search', $args), + 'hidden' => [ + 'token' => $this->c->Csrf->create('Search', $args), + ], + 'sets' => [], + 'btns' => [ + 'search' => [ + 'type' => 'submit', + 'value' => \ForkBB\__('Search btn'), + 'accesskey' => 's', + ], + ], + ]; + + if (isset($args['advanced'])) { + $form['sets'][] = [ + 'fields' => [ + [ + 'type' => 'info', + 'value' => \ForkBB\__('Simple search', $this->c->Router->link('Search')), + 'html' => true, + ], + 'keywords' => [ + 'dl' => 't2', + 'type' => 'text', + 'maxlength' => 100, + 'title' => \ForkBB\__('Keyword search'), + 'value' => $v ? $v->keywords : '', + 'required' => true, + 'autofocus' => true, + ], + 'author' => [ + 'dl' => 't1', + 'type' => 'text', + 'maxlength' => 25, + 'title' => \ForkBB\__('Author search'), + 'value' => $v ? $v->author : '', + ], + [ + 'type' => 'info', + 'value' => \ForkBB\__('Search info'), + ], + ], + ]; + $form['sets'][] = [ + 'legend' => \ForkBB\__('Search in legend'), + 'fields' => [ + 'forums' => [ + 'type' => 'multiselect', + 'options' => [], + 'value' => $v ? $v->forums : null, + 'title' => \ForkBB\__('Forum search'), + ], + 'serch_in' => [ + 'type' => 'select', + 'options' => [ + 0 => \ForkBB\__('Message and subject'), + 1 => \ForkBB\__('Message only'), + 2 => \ForkBB\__('Topic only'), + ], + 'value' => $v ? $v->serch_in : 0, + 'title' => \ForkBB\__('Search in'), + ], + [ + 'type' => 'info', + 'value' => \ForkBB\__('Search in info'), + ], + [ + 'type' => 'info', + 'value' => \ForkBB\__('Search multiple forums info'), + ], + + ], + ]; + $form['sets'][] = [ + 'legend' => \ForkBB\__('Search results legend'), + 'fields' => [ + 'sort_by' => [ + 'type' => 'select', + 'options' => [ + 0 => \ForkBB\__('Sort by post time'), + 1 => \ForkBB\__('Sort by author'), + 2 => \ForkBB\__('Sort by subject'), + 3 => \ForkBB\__('Sort by forum'), + ], + 'value' => $v ? $v->sort_by : 0, + 'title' => \ForkBB\__('Sort by'), + ], + 'sort_dir' => [ + 'type' => 'radio', + 'values' => [ + 0 => \ForkBB\__('Descending'), + 1 => \ForkBB\__('Ascending'), + ], + 'value' => $v ? $v->sort_dir : 0, + 'title' => \ForkBB\__('Sort order'), + ], + 'show_as' => [ + 'type' => 'radio', + 'values' => [ + 0 => \ForkBB\__('Show as posts'), + 1 => \ForkBB\__('Show as topics'), + ], + 'value' => $v ? $v->show_as : 0, + 'title' => \ForkBB\__('Show as'), + ], + [ + 'type' => 'info', + 'value' => \ForkBB\__('Search results info'), + ], + ], + + ]; + } else { + $form['sets'][] = [ + 'fields' => [ + [ + 'type' => 'info', + 'value' => \ForkBB\__('Advanced search', $this->c->Router->link('Search', ['advanced' => 'advanced'])), + 'html' => true, + ], + 'keywords' => [ + 'type' => 'text', + 'maxlength' => 100, + 'title' => \ForkBB\__('Keyword search'), + 'value' => $v ? $v->keywords : '', + 'required' => true, + 'autofocus' => true, + ], + ], + ]; + } + + $this->fIndex = 'search'; + $this->nameTpl = 'search'; + $this->onlinePos = 'search'; + $this->canonical = $this->c->Router->link('Search'); + $this->robots = 'noindex'; + $this->form = $form; + $this->crumbs = $this->crumbs([$this->c->Router->link('Search'), \ForkBB\__('Search')]); + + return $this; + } + + /** + * Дополнительная проверка строки запроса + * + * @param Validator $v + * @param string $query + * + * @return string + */ + public function vCheckQuery(Validator $v, $query) + { + $search = $this->c->search; + + if (! $search->prepare($query)) { + $v->addError(\ForkBB\__($search->queryError, $search->queryText)); + } + + return $query; + } + + /** + * Типовые действия + * + * @param array $args + * @param string $method + * + * @throws InvalidArgumentException + * + * @return Page + */ + public function action(array $args, $method) + { + $this->c->Lang->load('search'); + + $model = $this->c->search; + $model->page = isset($args['page']) ? (int) $args['page'] : 1; + $action = $args['action']; + switch ($action) { + case 'last': + case 'unanswered': + $list = $model->actionT($action); + $model->name = \ForkBB\__('Quick search show_' . $action); + $model->linkMarker = 'SearchAction'; + $model->linkArgs = ['action' => $action]; + break; + default: + throw new InvalidArgumentException('Unknown action: ' . $action); + } + + if (false === $list) { + return $this->c->Message->message('Bad request'); + } elseif (empty($list)) { + $this->a['fIswev']['i'][] = \ForkBB\__('No hits'); + return $this->view(['advanced' => 'advanced'], 'GET'); + } + + $this->fIndex = 'search'; + $this->nameTpl = 'forum'; + $this->onlinePos = 'search'; + $this->robots = 'noindex'; + $this->model = $model; + $this->topics = $list; + $this->crumbs = $this->crumbs($model); + $this->showForum = true; + + return $this; + } +} diff --git a/app/Models/Search/ActionT.php b/app/Models/Search/ActionT.php new file mode 100644 index 00000000..62309173 --- /dev/null +++ b/app/Models/Search/ActionT.php @@ -0,0 +1,64 @@ +c->forums->get(0); + if (! $root instanceof Forum || empty($root->descendants)) { + return []; //???? + } + + switch ($action) { + case 'last': + $sql = 'SELECT t.id + FROM ::topics AS t + WHERE t.forum_id IN (?ai:forums) AND t.moved_to IS NULL + ORDER BY t.last_post DESC'; + break; + case 'unanswered': + $sql = 'SELECT t.id + FROM ::topics AS t + WHERE t.forum_id IN (?ai:forums) AND t.moved_to IS NULL AND t.num_replies=0 + ORDER BY t.last_post DESC'; + break; + default: + throw new InvalidArgumentException('Unknown action: ' . $action); + } + $vars = [ + ':forums' => array_keys($root->descendants), + ]; + $list = $this->c->DB->query($sql, $vars)->fetchAll(PDO::FETCH_COLUMN); + + $this->model->numPages = (int) ceil((count($list) ?: 1) / $this->c->user->disp_topics); + + // нет такой страницы в результате поиска + if (! $this->model->hasPage()) { + return false; + // результат пуст + } elseif (empty($list)) { + return []; + } + + $this->model->idsList = array_slice($list, ($this->model->page - 1) * $this->c->user->disp_topics, $this->c->user->disp_topics); + + return $this->c->topics->view($this->model); + } +} diff --git a/app/Models/Search/Execute.php b/app/Models/Search/Execute.php new file mode 100644 index 00000000..84b74b97 --- /dev/null +++ b/app/Models/Search/Execute.php @@ -0,0 +1,222 @@ +model->queryWords) || ! is_string($this->model->queryText)) { + throw new InvalidArgumentException('No query data'); + } + +echo '
'; +var_dump($this->model->queryText); + + $this->words = []; + $this->stmt = null; + $vars = $this->buildSelect($options); + + $ids = $this->exec($this->model->queryWords, $vars); + + if ('asc' === $options['sort_dir']) { + asort($ids, $this->sortType); + } else { + arsort($ids, $this->sortType); + } + +var_dump($ids); +echo ''; + exit(); + } + + /** + * Поиск по словам рекурсивного списка + * + * @param array $words + * @param array $vars + * @param array $ids + * + * @return array + */ + protected function exec(array $words, array $vars, array $ids = []) + { + $type = 'AND'; + $count = 0; + + foreach ($words as $word) { + +var_dump($word); + + // служебное слово + if ('AND' === $word || 'OR' === $word || 'NOT' === $word) { + $type = $word; + continue; + } + + // если до сих пор ни чего не найдено и тип операции не ИЛИ, то выполнять не надо + if ($count && empty($ids) && 'OR' !== $type) { + continue; + } + + if (is_array($word) && (! isset($word['type']) || 'CJK' !== $word['type'])) { + $ids = $this->exec($word, $vars, $ids); + } else { + $CJK = false; + if (isset($word['type']) && 'CJK' === $word['type']) { + $CJK = true; + $word = $word['word']; //???? добавить * + } + + $word = str_replace(['*', '?'], ['%', '_'], $word); + + if (isset($this->words[$word])) { + $list = $this->words[$word]; + } else { + $vars[':word'] = $word; + + if (null === $this->stmt) { + $this->stmt = $this->c->DB->prepare($this->selectForIndex, $vars); + $this->stmt->execute(); + } else { + $this->stmt->execute($vars); + } + $this->words[$word] = $list = $this->stmt->fetchAll(PDO::FETCH_KEY_PAIR); + } + +var_dump($list); + if (! $count) { + $ids = $list; + } elseif ('AND' === $type) { + $ids = array_intersect_key($ids, $list); + } elseif ('OR' === $type) { + $ids += $list; + } elseif ('NOT' === $type) { + $ids = array_diff_key($ids, $list); + } + } + + ++$count; + } + + return $ids; + } + + /** + * @param array $options + * + * @return array + */ + protected function buildSelect(array $options) + { + # ["keywords"]=> string(5) "fnghj" + # ["author"] => string(0) "" + # ["forums"] => NULL + # ["serch_in"]=> string(3) "all" + # ["sort_by"] => string(4) "post" + # ["sort_dir"]=> string(4) "desc" + # ["show_as"] => string(5) "posts" + $vars = []; + $where = []; + $joinT = false; + $joinP = false; + + switch ($options['serch_in']) { + case 'posts': + $where[] = 'm.subject_match=0'; + break; + case 'topics': + $where[] = 'm.subject_match=1'; + // при поиске в заголовках результат только в виде списка тем + $options['show_as'] = 'topics'; + break; + } + + if (! empty($options['forums'])) { + $joinT = true; + $where[] = 't.forum_id IN (?ai:forums)'; + $vars[':forums'] = (array) $options['forums']; + } + + if ('topics' === $options['show_as']) { + $showTopics = true; + $joinP = true; + $selectF = 'p.topic_id'; + } else { + $showTopics = false; + $selectF = 'm.post_id'; + } + + //???? нужен индекс по авторам сообщений + //???? что делать с подчеркиванием в именах? + if ('' != $options['author']) { + $joinP = true; + $vars[':author'] = str_replace(['*', '?'], ['%', '_'], $options['author']); + $where[] = 'p.poster LIKE ?s:author'; + } + + switch ($options['sort_by']) { + case 'author': + if ($showTopics) { + $sortBy = 't.poster'; + $joinT = true; + } else { + $sortBy = 'p.poster'; + $joinP = true; + } + $this->sortType = SORT_STRING; + break; + case 'subject': + $sortBy = 't.subject'; + $joinT = true; + $this->sortType = SORT_STRING; + break; + case 'forum': + $sortBy = 't.forum_id'; + $joinT = true; + $this->sortType = SORT_NUMERIC; + break; + default: + if ($showTopics) { + $sortBy = 't.last_post'; + $joinT = true; + } else { + $sortBy = 'm.post_id'; + } + $this->sortType = SORT_NUMERIC; + break; + } + + $joinP = $joinP || $joinT ? 'INNER JOIN ::posts AS p ON p.id=m.post_id ' : ''; + $joinT = $joinT ? 'INNER JOIN ::topics AS t ON t.id=p.topic_id ' : ''; + $where = empty($where) ? '' : ' AND ' . implode(' AND ', $where); + + $this->selectForIndex = "SELECT {$selectF}, {$sortBy} FROM ::search_words AS w " . + 'INNER JOIN ::search_matches AS m ON m.word_id=w.id ' . + $joinP . + $joinT . + 'WHERE w.word LIKE ?s:word' . $where; + + return $vars; + } +} diff --git a/app/Models/Search/Index.php b/app/Models/Search/Index.php new file mode 100644 index 00000000..0ec4ce0f --- /dev/null +++ b/app/Models/Search/Index.php @@ -0,0 +1,150 @@ +words(mb_strtolower($this->c->Parser->getText(), 'UTF-8')); + $subWords = $post->id === $post->parent->first_post_id + ? $this->words(mb_strtolower($post->parent->subject, 'UTF-8')) + : []; + + if ('add' !== $mode) { + $vars = [ + ':pid' => $post->id, + ]; + $sql = 'SELECT w.id, w.word, m.subject_match + FROM ::search_words AS w + INNER JOIN ::search_matches AS m ON w.id=m.word_id + WHERE m.post_id=?i:pid'; + $stmt = $this->c->DB->query($sql, $vars); + + $mesCurWords = []; + $subCurWords = []; + while ($row = $stmt->fetch()) { + if ($row['subject_match']) { + $subCurWords[$row['word']] = $row['id']; + } else { + $mesCurWords[$row['word']] = $row['id']; + } + } + } + + $words = []; + if ('edit' === $mode) { + $words['add']['p'] = array_diff($mesWords, array_keys($mesCurWords)); + $words['add']['s'] = array_diff($subWords, array_keys($subCurWords)); + $words['del']['p'] = array_diff_key($mesCurWords, array_flip($mesWords)); + $words['del']['s'] = array_diff_key($subCurWords, array_flip($subWords)); + } elseif ('merge' === $mode) { + $words['add']['p'] = array_diff($mesWords, array_keys($mesCurWords)); + $words['add']['s'] = array_diff($subWords, array_keys($subCurWords)); + $words['del']['p'] = []; + $words['del']['s'] = []; + } else { + $words['add']['p'] = $mesWords; + $words['add']['s'] = $subWords; + $words['del']['p'] = []; + $words['del']['s'] = []; + } + + if (empty($words['add']['s'])) { + $allWords = $words['add']['p']; + } else { + $allWords = array_unique(array_merge($words['add']['p'], $words['add']['s'])); + } + if (! empty($allWords)) { + $vars = [ + ':words' => $allWords, + ]; + $sql = 'SELECT word + FROM ::search_words + WHERE word IN(?as:words)'; + $oldWords = $this->c->DB->query($sql, $vars)->fetchAll(PDO::FETCH_COLUMN); + $newWords = array_diff($allWords, $oldWords); + + if (! empty($newWords)) { + $sql = 'INSERT INTO ::search_words (word) VALUES(?s:word)'; + $stmt = null; + foreach ($newWords as $word) { + if (null === $stmt) { + $stmt = $this->c->DB->prepare($sql, [':word' => $word]); + $stmt->execute(); + } else { + $stmt->execute([':word' => $word]); + } + } + } + } + + foreach ($words['del'] as $key => $list) { + if (empty($list)) { + continue; + } + + $vars = [ + ':pid' => $post->id, + ':subj' => 's' === $key ? 1 : 0, + ':ids' => $list, + ]; + $sql = 'DELETE FROM ::search_matches + WHERE word_id IN(?ai:ids) AND post_id=?i:pid AND subject_match=?i:subj'; + $this->c->DB->exec($sql, $vars); + } + + foreach ($words['add'] as $key => $list) + { + if (empty($list)) { + continue; + } + + $vars = [ + ':pid' => $post->id, + ':subj' => 's' === $key ? 1 : 0, + ':words' => $list, + ]; + $sql = 'INSERT INTO ::search_matches (post_id, word_id, subject_match) + SELECT ?i:pid, id, ?i:subj + FROM ::search_words + WHERE word IN(?as:words)'; + $this->c->DB->exec($sql, $vars); + } + } + + /** + * Получение слов из текста для построения поискового индекса + * + * @param string $text + * + * @return array + */ + protected function words($text) + { + $text = $this->model->cleanText($text, true); + + $words = []; + foreach (array_unique(explode(' ', $text)) as $word) { + $word = $this->model->word($word, true); + if (null !== $word) { + $words[] = $word; + } + } + return $words; + } +} diff --git a/app/Models/Search/Model.php b/app/Models/Search/Model.php new file mode 100644 index 00000000..e3edf5ae --- /dev/null +++ b/app/Models/Search/Model.php @@ -0,0 +1,144 @@ +c->Router->link($this->linkMarker, $this->linkArgs); + } + + /** + * Массив страниц результата поиска + * + * @return array + */ + protected function getpagination() + { + return $this->c->Func->paginate($this->numPages, $this->page, $this->linkMarker, $this->linkArgs); + } + + /** + * Статус наличия установленной страницы в результате поиска + * + * @return bool + */ + public function hasPage() + { + return $this->page > 0 && $this->page <= $this->numPages; + } + + /** + * Очистка текста для дальнейшего разделения на слова + * + * @param string $text + * @param bool $indexing + * + * @return string + */ + public function cleanText($text, $indexing = false) + { + $text = str_replace(['`', '’', 'ё'], ['\'', '\'', 'е'], $text); + // четыре одинаковых буквы в одну + $text = preg_replace('%(\p{L})\1{3,}%u', '\1', $text); + // удаление ' и - вне слов + $text = preg_replace('%((?c->stopwords->list[$word])) { + return null; + } + + if ($this->isCJKWord($word)) { + return $indexing ? null : $word; + } + + $len = mb_strlen(trim($word, '?*'), 'UTF-8'); + + if ($len < 3) { + return null; + } + + if ($len > 20) { + $word = mb_substr($word, 0, 20, 'UTF-8'); + } + + return $word; + } + + /** + * Проверка слова на язык CJK + * + * @param string $word + * + * @return bool + */ + public function isCJKWord($word) + { + return preg_match('%' . self::CJK_REGEX . '%u', $word) ? true : false; //????? + } +} diff --git a/app/Models/Search/Prepare.php b/app/Models/Search/Prepare.php new file mode 100644 index 00000000..bc301fce --- /dev/null +++ b/app/Models/Search/Prepare.php @@ -0,0 +1,210 @@ +model->queryError = 'Odd number of quotes: \'%s\''; + $this->model->queryWords = []; + $this->model->queryText = $query; + return false; + } + + $error = null; + $this->model->queryWords = null; + $this->model->queryText = null; + + $stack = []; + $quotes = false; + $words = []; + $keyword = true; + $count = 0; + + foreach (preg_split('%"%', $query) as $subQuery) { + // подстрока внутри кавычек + if ($quotes) { + $subQuery = mb_strtolower(trim($subQuery), 'UTF-8'); + // не стоп-слово и минимальная длина удовлетворяет условию + if (null !== $this->model->word($subQuery)) { + // подстрока является словом и нет символов CJK языков + if (false === strpos($subQuery, ' ') + && ! $this->model->isCJKWord($subQuery) + && $this->model->cleanText($subQuery) === $subQuery + ) { + $words[] = $subQuery; + // это не слово или есть символы CJK языков + // искать придется через LIKE по тексту сообщений + } else { + $words[] = ['type' => 'CJK', 'word' => $subQuery]; + } + $keyword = false; + ++$count; + } + $quotes = false; + continue; + } + + // действуют управляющие слова + foreach ( + preg_split( + '%\s*(\b(?:AND|OR|NOT)\b|(?model->cleanText($cur); //???? + $temp = []; + $countT = 0; + foreach (explode(' ', $cur) as $word) { + $word = $this->model->word($word); + if (null === $word) { + continue; + } + if (! empty($temp)) { + $temp[] = 'AND'; + } + if ($this->model->isCJKWord($word)) { + $temp[] = ['type' => 'CJK', 'word' => $word]; + } elseif (rtrim($word, '?*') === $word) { + $temp[] = $word . '*'; //???? + } else { + $temp[] = $word; + } + ++$countT; + } + if ($countT) { + if (! $keyword) { + $words[] = 'AND'; + } + if (1 === $countT || 'AND' === end($words)) { + $words = array_merge($words, $temp); + $count += $countT; + } else { + $words[] = $temp; + ++$count; + } + $keyword = false; + } + break; + } + } + $quotes = true; + } + + if (! $count) { + $error = 'There is no word for search: "%s"'; + } else if ($keyword) { + $error = 'Syntactic word at the end of the search query: "%s"'; + } elseif (! empty($stack)) { + $error = 'The order of brackets is broken: \'%s\''; + } + + $this->model->queryError = $error; + $this->model->queryWords = $words; + $this->model->queryText = $this->queryText($words); + + return null === $error; + } + + /** + * Замена в строке по массиву шаблонов + * + * @param string $str + * @param array $repl + * + * @return string + */ + protected function repl($str, array $repl) + { + return preg_replace(array_keys($repl), array_values($repl), $str); + } + + /** + * Восстановление текста запроса по массиву слов + * + * @param array $words + * + * @return string + */ + protected function queryText(array $words) + { + $space = ''; + $result = ''; + foreach ($words as $word) { + if (isset($word['type']) && 'CJK' === $word['type']) { + $word = '"' . $word['word'] . '"'; + } elseif (is_array($word)) { + $word = '(' . $this->queryText($word) . ')'; + } + $result .= $space . $word; + $space = ' '; + } + return $result; + } +} diff --git a/app/Models/Stopwords.php b/app/Models/Stopwords.php index 69248f1f..510e35d5 100644 --- a/app/Models/Stopwords.php +++ b/app/Models/Stopwords.php @@ -74,6 +74,7 @@ class Stopwords extends Model // Tidy up and filter the stopwords $stopwords = array_map('trim', $stopwords); $stopwords = array_filter($stopwords); + $stopwords = array_flip($stopwords); $this->c->Cache->set('stopwords', ['id' => $id, 'stopwords' => $stopwords]); $this->list = $stopwords; diff --git a/app/Models/Topic/View.php b/app/Models/Topic/View.php index ae8ebafd..1ce07d21 100644 --- a/app/Models/Topic/View.php +++ b/app/Models/Topic/View.php @@ -3,28 +3,41 @@ namespace ForkBB\Models\Topic; use ForkBB\Models\Action; +use ForkBB\Models\Forum\Model as Forum; +use ForkBB\Models\Search\Model as Search; use ForkBB\Models\Topic\Model as Topic; use PDO; +use InvalidArgumentException; +use RuntimeException; class View extends Action { /** * Возвращает список тем * - * @param array $list - * @param bool $expanded + * @param mixed $arg * + * @throws InvalidArgumentException + * * @return array */ - public function view(array $list, $expanded = false) + public function view($arg) { - if (empty($list)) { - return []; + if ($arg instanceof Forum) { + $expanded = false; + } elseif ($arg instanceof Search) { + $expanded = true; + } else { + throw new InvalidArgumentException('Expected Forum or Search'); + } + + if (empty($arg->idsList) || ! is_array($arg->idsList)) { + throw new RuntimeException('Model does not contain a list of topics to display'); } $vars = [ ':uid' => $this->c->user->id, - ':ids' => $list, + ':ids' => $arg->idsList, ]; if (! $this->c->user->isGuest && '1' == $this->c->config->o_show_dot) { @@ -56,7 +69,7 @@ class View extends Action } $stmt = $this->c->DB->query($sql, $vars); - $result = array_flip($list); + $result = array_flip($arg->idsList); while ($row = $stmt->fetch()) { $row['dot'] = isset($dots[$row['id']]); $result[$row['id']] = $this->manager->create($row); diff --git a/app/lang/English/search.po b/app/lang/English/search.po new file mode 100644 index 00000000..611c5915 --- /dev/null +++ b/app/lang/English/search.po @@ -0,0 +1,190 @@ +# +msgid "" +msgstr "" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Project-Id-Version: ForkBB\n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: ForkBB
{!! __('by') !!} {{ $topic->poster }}
+ @if ($p->showForum) + @endif{{ $cur['value'] }}
- @else -{!! $cur['value'] !!}
- @endif +@if (empty($cur['html'])){{ $cur['value'] }} @else{!! $cur['value'] !!} @endif
@endforeach @elseif (isset($set['fields'])) @endif diff --git a/app/templates/layouts/main.tpl b/app/templates/layouts/main.tpl index c5b4794c..1cfbc334 100644 --- a/app/templates/layouts/main.tpl +++ b/app/templates/layouts/main.tpl @@ -17,7 +17,9 @@{!! $p->fDescription !!}
+@endif