Browse Source

2017-10-03

Visman 7 years ago
parent
commit
495166e21c
53 changed files with 3356 additions and 225 deletions
  1. 1 1
      .gitignore
  2. 1 1
      app/Controllers/Routing.php
  3. 78 0
      app/Models/Pages/CrumbTrait.php
  4. 2 24
      app/Models/Pages/Forum.php
  5. 5 46
      app/Models/Pages/Index.php
  6. 59 0
      app/Models/Pages/OnlineTrait.php
  7. 3 0
      app/Models/Pages/Rules.php
  8. 169 80
      app/Models/Pages/Topic.php
  9. 2 2
      app/Models/Pages/UsersTrait.php
  10. 510 0
      app/config/defaultBBCode.php
  11. 5 8
      app/lang/English/index.po
  12. 10 4
      app/lang/English/topic.po
  13. 5 8
      app/lang/Russian/index.po
  14. 10 4
      app/lang/Russian/topic.po
  15. 1 35
      app/templates/index.tpl
  16. 1 1
      app/templates/layouts/redirect.tpl
  17. 39 0
      app/templates/layouts/stats.tpl
  18. 18 7
      app/templates/topic.tpl
  19. 2 1
      composer.json
  20. 44 2
      composer.lock
  21. 1 1
      public/.htaccess
  22. BIN
      public/img/sm/big_smile.png
  23. BIN
      public/img/sm/cool.png
  24. BIN
      public/img/sm/hmm.png
  25. BIN
      public/img/sm/lol.png
  26. BIN
      public/img/sm/mad.png
  27. BIN
      public/img/sm/neutral.png
  28. BIN
      public/img/sm/roll.png
  29. BIN
      public/img/sm/sad.png
  30. BIN
      public/img/sm/smile.png
  31. BIN
      public/img/sm/tongue.png
  32. BIN
      public/img/sm/wink.png
  33. BIN
      public/img/sm/yikes.png
  34. 5 0
      public/style/ForkBB/style.css
  35. 1 0
      vendor/composer/autoload_namespaces.php
  36. 11 0
      vendor/composer/autoload_static.php
  37. 44 0
      vendor/composer/installed.json
  38. 21 0
      vendor/miovisman/parserus/LICENSE
  39. 1222 0
      vendor/miovisman/parserus/Parserus.php
  40. 40 0
      vendor/miovisman/parserus/README.md
  41. 21 0
      vendor/miovisman/parserus/composer.json
  42. 20 0
      vendor/miovisman/parserus/examples/_first.php
  43. 20 0
      vendor/miovisman/parserus/examples/_second.php
  44. 20 0
      vendor/miovisman/parserus/examples/_third.php
  45. 50 0
      vendor/miovisman/parserus/examples/attr.php
  46. 554 0
      vendor/miovisman/parserus/examples/bbcodes_test.php
  47. 27 0
      vendor/miovisman/parserus/examples/detectUrls.php
  48. 63 0
      vendor/miovisman/parserus/examples/detectUrls2.php
  49. 9 0
      vendor/miovisman/parserus/examples/e.php
  50. 126 0
      vendor/miovisman/parserus/examples/getCode.php
  51. 41 0
      vendor/miovisman/parserus/examples/getErrors.php
  52. 34 0
      vendor/miovisman/parserus/examples/smilies.php
  53. 61 0
      vendor/miovisman/parserus/examples/stripEmptyTags.php

+ 1 - 1
.gitignore

@@ -2,4 +2,4 @@
 /app/config/main.php
 /app/cache/**/*.php
 /app/cache/**/*.lock
-/public/avatar/*
+/public/img/avatars/*

+ 1 - 1
app/Controllers/Routing.php

@@ -81,7 +81,7 @@ class Routing
             $r->add('GET', '/forum/{id:[1-9]\d*}/new/topic', 'Post:newTopic', 'NewTopic');
             $r->add('GET', '/forum/{id:[1-9]\d*}[/{name}][/page/{page:[1-9]\d*}]', 'Forum:view', 'Forum');
             // темы
-            $r->add('GET', '/topic/{id:[1-9]\d*}[/{name}][/page/{page:[1-9]\d*}]', 'Topic:view', 'Topic');
+            $r->add('GET', '/topic/{id:[1-9]\d*}[/{name}][/page/{page:[1-9]\d*}]', 'Topic:viewTopic', 'Topic');
             $r->add('GET', '/topic/{id:[1-9]\d*}/goto/new', 'Topic:goToNew', 'TopicGoToNew');
             $r->add('GET', '/topic/{id:[1-9]\d*}/goto/unread', 'Topic:goToUnread', 'TopicGoToUnread');
             $r->add('GET', '/topic/{id:[1-9]\d*}/goto/last', 'Topic:goToLast', 'TopicGoToLast');

+ 78 - 0
app/Models/Pages/CrumbTrait.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace ForkBB\Models\Pages;
+
+trait CrumbTrait 
+{
+    /**
+     * Возвращает массив хлебных крошек
+     * @param mixed $args
+     * @return array
+     */
+    protected function getCrumbs(...$args)
+    {
+        $crumbs = [];
+        $active = true;
+
+        foreach ($args as $arg) {
+            if (is_array($arg)) {
+                $cur = array_shift($arg);
+                // массив разделов
+                if (is_array($cur)) {
+                    $id = $arg[0];
+                    while (true) {
+                        $this->titles[] = $cur[$id]['forum_name'];
+                        $crumbs[] = [
+                            $this->c->Router->link('Forum', ['id' => $id, 'name' => $cur[$id]['forum_name']]),
+                            $cur[$id]['forum_name'], 
+                            $active,
+                        ];
+                        $active = null;
+                        if (! isset($cur[$id][0])) {
+                            break;
+                        }
+                        $id = $cur[$id][0];
+                    }
+                // отдельная страница
+                } else {
+                    // определение названия
+                    if (isset($arg[1])) {
+                        $vars = $arg[0];
+                        $name = $arg[1];
+                    } elseif (is_string($arg[0])) {
+                        $vars = [];
+                        $name = $arg[0];
+                    } elseif (isset($arg[0]['name'])) {
+                        $vars = $arg[0];
+                        $name = $arg[0]['name'];
+                    } else {
+                        continue;
+                    }
+                    $this->titles[] = $name;
+                    $crumbs[] = [
+                        $this->c->Router->link($cur, $vars),
+                        $name, 
+                        $active,
+                    ];
+                }
+            // предположительно идет только название, без ссылки
+            } else {
+                $this->titles[] = (string) $arg;
+                $crumbs[] = [
+                    null,
+                    (string) $arg,
+                    $active,
+                ];
+            }
+            $active = null;
+        }
+        // главная страница
+        $crumbs[] = [
+            $this->c->Router->link('Index'),
+            __('Index'),
+            $active,
+        ];
+
+        return array_reverse($crumbs);
+    }
+}

+ 2 - 24
app/Models/Pages/Forum.php

@@ -5,6 +5,7 @@ namespace ForkBB\Models\Pages;
 class Forum extends Page
 {
     use ForumsTrait;
+    use CrumbTrait;
 
     /**
      * Имя шаблона
@@ -189,33 +190,10 @@ class Forum extends Page
 
         $this->onlinePos = 'forum-' . $args['id'];
 
-        $crumbs = [];
-        $id = $args['id'];
-        $activ = true;
-        while (true) {
-            $name = $fDesc[$id]['forum_name'];
-            $this->titles[] = $name;
-            $crumbs[] = [
-                $this->c->Router->link('Forum', ['id' => $id, 'name' => $name]),
-                $name, 
-                $activ,
-            ];
-            $activ = null;
-            if (! isset($fDesc[$id][0])) {
-                break;
-            }
-            $id = $fDesc[$id][0];
-        }
-        $crumbs[] = [
-            $this->c->Router->link('Index'),
-            __('Index'),
-            null,
-        ];
-
         $this->data = [
             'forums' => $this->getForumsData($args['id']),
             'topics' => $topics,
-            'crumbs' => array_reverse($crumbs),
+            'crumbs' => $this->getCrumbs([$fDesc, $args['id']]),
             'forumName' => $fDesc[$args['id']]['forum_name'],
             'newTopic' => $newOn ? $this->c->Router->link('NewTopic', ['id' => $args['id']]) : null,
             'pages' => $this->c->Func->paginate($pages, $page, 'Forum', ['id' => $args['id'], 'name' => $fDesc[$args['id']]['forum_name']]),

+ 5 - 46
app/Models/Pages/Index.php

@@ -5,6 +5,7 @@ namespace ForkBB\Models\Pages;
 class Index extends Page
 {
     use ForumsTrait;
+    use OnlineTrait;
 
     /**
      * Имя шаблона
@@ -60,53 +61,11 @@ class Index extends Page
             $stats['newest_user'] = $stats['last_user']['username'];
         }
         $this->data['stats'] = $stats;
-
-        // вывод информации об онлайн посетителях
-        if ($this->config['o_users_online'] == '1') {
-            $this->data['online'] = [];
-            $this->data['online']['max'] = $this->number($this->config['st_max_users']);
-            $this->data['online']['max_time'] = $this->time($this->config['st_max_users_time']);
-
-            // данные онлайн посетителей
-            list($users, $guests, $bots) = $this->c->Online->handle($this);
-            $list = [];
-
-            if ($this->c->user->gViewUsers == '1') {
-                foreach ($users as $id => $cur) {
-                    $list[] = [
-                        $this->c->Router->link('User', [
-                            'id' => $id,
-                            'name' => $cur['name'],
-                        ]),
-                        $cur['name'],
-                    ];
-                }
-            } else {
-                foreach ($users as $cur) {
-                    $list[] = $cur['name'];
-                }
-            }
-            $this->data['online']['number_of_users'] = $this->number(count($users));
-
-            $s = 0;
-            foreach ($bots as $name => $cur) {
-                $count = count($cur);
-                $s += $count;
-                if ($count > 1) {
-                    $list[] = '[Bot] ' . $name . ' (' . $count . ')';
-                } else {
-                    $list[] = '[Bot] ' . $name;
-                }
-            }
-            $s += count($guests);
-            $this->data['online']['number_of_guests'] = $this->number($s);
-            $this->data['online']['list'] = $list;
-        } else {
-            $this->onlineType = false;
-            $this->c->Online->handle($this);
-            $this->data['online'] = null;
-        }
+        $this->data['online'] = $this->getUsersOnlineInfo();
         $this->data['forums'] = $this->getForumsData();
+
+        $this->canonical = $this->c->Router->link('Index');
+        
         return $this;
     }
 }

+ 59 - 0
app/Models/Pages/OnlineTrait.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace ForkBB\Models\Pages;
+
+trait OnlineTrait 
+{
+    /**
+     * Получение информации об онлайн посетителях
+     * @return null|array
+     */
+    protected function getUsersOnlineInfo() 
+    {
+        if ($this->config['o_users_online'] == '1') {
+            $data = [
+                'max'      => $this->number($this->config['st_max_users']),
+                'max_time' => $this->time($this->config['st_max_users_time']),
+            ];
+
+            // данные онлайн посетителей
+            list($users, $guests, $bots) = $this->c->Online->handle($this);
+            $list = [];
+
+            if ($this->c->user->gViewUsers == '1') {
+                foreach ($users as $id => $cur) {
+                    $list[] = [
+                        $this->c->Router->link('User', [
+                            'id' => $id,
+                            'name' => $cur['name'],
+                        ]),
+                        $cur['name'],
+                    ];
+                }
+            } else {
+                foreach ($users as $cur) {
+                    $list[] = $cur['name'];
+                }
+            }
+            $data['number_of_users'] = $this->number(count($users));
+
+            $s = 0;
+            foreach ($bots as $name => $cur) {
+                $count = count($cur);
+                $s += $count;
+                if ($count > 1) {
+                    $list[] = '[Bot] ' . $name . ' (' . $count . ')';
+                } else {
+                    $list[] = '[Bot] ' . $name;
+                }
+            }
+            $s += count($guests);
+            $data['number_of_guests'] = $this->number($s);
+            $data['list'] = $list;
+            return $data;
+        } else {
+            $this->onlineType = false;
+            return null;
+        }
+    }
+}

+ 3 - 0
app/Models/Pages/Rules.php

@@ -34,6 +34,9 @@ class Rules extends Page
             'rules' => $this->config['o_rules_message'],
             'formAction' => null,
         ];
+
+        $this->canonical = $this->c->Router->link('Rules');
+
         return $this;
     }
 

+ 169 - 80
app/Models/Pages/Topic.php

@@ -5,7 +5,9 @@ namespace ForkBB\Models\Pages;
 class Topic extends Page
 {
     use UsersTrait;
-
+    use OnlineTrait;
+    use CrumbTrait;
+    
     /**
      * Имя шаблона
      * @var string
@@ -18,6 +20,22 @@ class Topic extends Page
      */
     protected $onlinePos = 'topic';
 
+    /**
+     * Тип обработки пользователей онлайн
+     * Если false, то идет обновление данных
+     * Если true, то идет возврат данных (смотрите $onlineFilter)
+     * @var bool
+     */
+    protected $onlineType = true;
+     
+    /**
+     * Тип возврата данных при onlineType === true
+     * Если true, то из online должны вернутся только пользователи находящиеся на этой же странице
+     * Если false, то все пользователи online
+     * @var bool
+     */
+    protected $onlineFilter = true;
+
     /**
      * Подготовка данных для шаблона
      * @param array $args
@@ -69,44 +87,37 @@ class Topic extends Page
         $vars = [
             ':pid' => $args['id'],
         ];
-        $sql = 'SELECT topic_id FROM ::posts WHERE id=?i:pid';
 
-        $tid = $this->c->DB->query($sql, $vars)->fetchColumn();
-        // сообшение не найдено в базе
-        if (empty($tid)) {
-            return $this->c->Message->message('Bad request');
+        if ($this->c->user->isGuest) {
+            $sql = 'SELECT t.*, f.moderators, 0 AS is_subscribed 
+                    FROM ::topics AS t 
+                    INNER JOIN ::forums AS f ON f.id=t.forum_id 
+                    INNER JOIN ::posts AS p ON t.id=p.topic_id
+                    WHERE p.id=?i:pid AND t.moved_to IS NULL';
+        } else {
+            $sql = 'SELECT t.*, f.moderators, s.user_id AS is_subscribed 
+                    FROM ::topics AS t 
+                    INNER JOIN ::forums AS f ON f.id=t.forum_id 
+                    INNER JOIN ::posts AS p ON t.id=p.topic_id
+                    LEFT JOIN ::topic_subscriptions AS s ON (t.id=s.topic_id AND s.user_id=?i:uid) 
+                    WHERE p.id=?i:pid AND t.moved_to IS NULL';
         }
 
-        $vars = [
-            ':pid' => $args['id'],
-            ':tid' => $tid,
-        ];
-        $sql = 'SELECT COUNT(id) FROM ::posts WHERE topic_id=?i:tid AND id<?i:pid';
-
-        $num = 1 + $this->c->DB->query($sql, $vars)->fetchColumn();
-
-        return $this->view([
-            'id' => $tid,
-            'page' => ceil($num / $this->c->user->dispPosts),
-        ]);
+        return $this->view($sql, $vars, null);
     }
 
     /**
-     * Подготовка данных для шаблона
+     * Просмотр темы по ее номеру
      * @param array $args
      * @return Page
      */
-    public function view(array $args)
+    public function viewTopic(array $args)
     {
-        $this->c->Lang->load('topic');
-
-        $user = $this->c->user;
         $vars = [
             ':tid' => $args['id'],
-            ':uid' => $user->id,
         ];
 
-        if ($user->isGuest) {
+        if ($this->c->user->isGuest) {
             $sql = 'SELECT t.*, f.moderators, 0 AS is_subscribed 
                     FROM ::topics AS t 
                     INNER JOIN ::forums AS f ON f.id=t.forum_id 
@@ -118,62 +129,88 @@ class Topic extends Page
                     LEFT JOIN ::topic_subscriptions AS s ON (t.id=s.topic_id AND s.user_id=?i:uid) 
                     WHERE t.id=?i:tid AND t.moved_to IS NULL';
         }
-        $topic = $this->c->DB->query($sql, $vars)->fetch();
 
+        $page = isset($args['page']) ? (int) $args['page'] : 1;
+
+        return $this->view($sql, $vars, $page);
+    }
+
+    /**
+     * Подготовка данных для шаблона
+     * @param string $sql
+     * @param array $vars
+     * @param int|null $page
+     * @return Page
+     */
+     protected function view($sql, array $vars, $page)
+     {
+        $user = $this->c->user;
+
+        $vars[':uid'] = $user->id;
+
+        $topic = $this->c->DB->query($sql, $vars)->fetch();
+        
         // тема отсутствует или недоступна
         if (empty($topic)) {
             return $this->c->Message->message('Bad request');
         }
-
+        
         list($fTree, $fDesc, $fAsc) = $this->c->forums;
-
+        
         // раздел отсутствует в доступных
         if (empty($fDesc[$topic['forum_id']])) {
             return $this->c->Message->message('Bad request');
         }
+        
+        if (null === $page) {
+            $vars[':tid'] = $topic['id'];
+            $sql = 'SELECT COUNT(id) FROM ::posts WHERE topic_id=?i:tid AND id<?i:pid';
 
-        $page = isset($args['page']) ? (int) $args['page'] : 1;
-        $pages = ceil(( $topic['num_replies'] + 1) / $user->dispPosts);
+            $num = 1 + $this->c->DB->query($sql, $vars)->fetchColumn();
 
+            $page = ceil($num / $user->dispPosts);
+        }
+
+        $this->c->Lang->load('topic');
+
+        $pages = ceil(($topic['num_replies'] + 1) / $user->dispPosts);
         // попытка открыть страницу которой нет
         if ($page < 1 || $page > $pages) {
             return $this->c->Message->message('Bad request');
         }
 
         $offset = ($page - 1) * $user->dispPosts;
-        
         $vars = [
-            ':tid' => $args['id'],
+            ':tid'    => $topic['id'],
             ':offset' => $offset,
-            ':rows' => $user->dispPosts,
+            ':rows'   => $user->dispPosts,
         ];
         $sql = 'SELECT id 
                 FROM ::posts 
                 WHERE topic_id=?i:tid 
                 ORDER BY id LIMIT ?i:offset, ?i:rows';
+
         $ids = $this->c->DB->query($sql, $vars)->fetchAll(\PDO::FETCH_COLUMN);
 
         // нарушена синхронизация количества сообщений в темах
         if (empty($ids)) {
-            return $this->goToLast($args); //????
+            return $this->goToLast($topic['id']);
         }
 
         $moders = empty($topic['moderators']) ? [] : array_flip(unserialize($topic['moderators']));
-
         $parent = isset($fDesc[$topic['forum_id']][0]) ? $fDesc[$topic['forum_id']][0] : 0;
         $perm = $fTree[$parent][$topic['forum_id']];
 
+        $newOn = null;
         if ($user->isAdmin) {
-            $newPost = $this->c->Router->link('NewPost', ['id' => $args['id']]);
+            $newOn = true;
         } elseif ($topic['closed'] == '1') {
-            $newPost = false;
+            $newOn = false;
         } elseif ($perm['post_replies'] === 1 
             || (null === $perm['post_replies'] && $user->gPostReplies == '1')
             || ($user->isAdmMod && isset($moders[$user->id]))
         ) {
-            $newPost = $this->c->Router->link('NewPost', ['id' => $args['id']]);
-        } else {
-            $newPost = null;
+            $newOn = true;
         }
 
         // приклейка первого сообщения темы
@@ -188,6 +225,7 @@ class Topic extends Page
         $sql = 'SELECT id, message, poster, posted 
                 FROM ::warnings 
                 WHERE id IN (?ai:ids)';
+
         $warnings = $this->c->DB->query($sql, $vars)->fetchAll(\PDO::FETCH_GROUP);
         
         $vars = [
@@ -202,11 +240,33 @@ class Topic extends Page
                 INNER JOIN ::users AS u ON u.id=p.poster_id 
                 INNER JOIN ::groups AS g ON g.g_id=u.group_id 
                 WHERE p.id IN (?ai:ids) ORDER BY p.id';
+
         $stmt = $this->c->DB->query($sql, $vars);
 
+        // парсер и его настройка для сообщений
+        $bbcodes = include $this->c->DIR_CONFIG . '/defaultBBCode.php';
+        $smilies = $this->c->smilies;
+        foreach ($smilies as &$cur) {
+            $cur = $this->c->PUBLIC_URL . '/img/sm/' . $cur;
+        }
+        unset($cur);
+        $bbInfo = $this->c->BBCODE_INFO;
+        $bbWList = $this->config['p_message_bbcode'] == '1' ? null : [];
+        $bbBList = $this->config['p_message_img_tag'] == '1' ? [] : ['img'];
+        $parser = $this->c->Parser;
+        $parser->setBBCodes($bbcodes)
+               ->setAttr('isSign', false)
+               ->setWhiteList($bbWList)
+               ->setBlackList($bbBList);
+        if ($user->showSmilies == '1') {
+            $parser->setSmilies($smilies)
+                   ->setSmTpl($bbInfo['smTpl'], $bbInfo['smTplTag'], $bbInfo['smTplBl']);
+        }
+
         $genders = [1 => ' f-user-male', 2 => ' f-user-female'];
         $postCount = 0;
         $posts = [];
+        $signs = [];
         $posters = [];
         while ($cur = $stmt->fetch()) {
             // данные по автору сообшения
@@ -215,6 +275,7 @@ class Topic extends Page
             } else {
                 $post = [
                     'poster'            => $cur['username'],
+                    'poster_id'         => $cur['poster_id'],
                     'poster_title'      => $this->censor($this->userGetTitle($cur)),
                     'poster_avatar'     => null,
                     'poster_registered' => null,
@@ -251,6 +312,14 @@ class Topic extends Page
                     $post['poster_online'] = ' f-user-online'; //????
 
                     $posters[$cur['poster_id']] = $post;
+
+                    if ($this->config['o_signatures'] == '1' 
+                        && $cur['signature'] != '' 
+                        && $user->showSig == '1' 
+                        && ! isset($signs[$cur['poster_id']])
+                    ) {
+                        $signs[$cur['poster_id']] = $cur['signature'];
+                    }
                 }
             }
 
@@ -260,6 +329,12 @@ class Topic extends Page
             $post['posted']     = $this->time($cur['posted']);
             $post['posted_utc'] = gmdate('Y-m-d\TH:i:s\Z', $cur['posted']);
 
+            $parser->parse($this->censor($cur['message']));
+            if ($this->config['o_smilies'] == '1' && $user->showSmilies == '1' && $cur['hide_smilies'] == '0') {
+                $parser->detectSmilies();
+            }
+            $post['message'] = $parser->getHtml();
+
             // номер сообшения в теме
             if ($stickFP && $offset > 0 && $cur['id'] == $topic['first_post_id']) {
                 $post['post_number'] = 1;
@@ -274,12 +349,22 @@ class Topic extends Page
                 $controls['report'] = ['#', 'Report'];
             }
             if ($user->isAdmin 
-                || ($user->isAdmMod && isset($moders[$user->id]))
-                || ($cur['poster_id'] == $user->id) //????
+                || ($user->isAdmMod && isset($moders[$user->id]) && ! in_array($cur['poster_id'], $this->c->admins))
             ) {
+                $controls['delete'] = ['#', 'Delete'];
                 $controls['edit'] = ['#', 'Edit'];
+            } elseif ($topic['closed'] != '1' 
+                && $cur['poster_id'] == $user->id
+                && ($user->gDeleditInterval == '0' || $cur['edit_post'] == '1' || time() - $cur['posted'] < $user->gDeleditInterval)
+            ) {
+                if (($cur['id'] == $topic['first_post_id'] && $user->gDeleteTopics == '1') || ($cur['id'] != $topic['first_post_id'] && $user->gDeletePosts == '1')) {
+                    $controls['delete'] = ['#', 'Delete'];
+                }
+                if ($user->gEditPosts == '1') {
+                    $controls['edit'] = ['#', 'Edit'];
+                }
             }
-            if ($newPost) {
+            if ($newOn) {
                 $controls['quote'] = ['#', 'Reply'];
             }
 
@@ -288,50 +373,54 @@ class Topic extends Page
             $posts[] = $post;
         }
 
-        $topic['subject'] = $this->censor($topic['subject']);
-        
-        $crumbs = [];
-        $crumbs[] = [
-            $this->c->Router->link('Topic', ['id' => $args['id'], 'name' => $topic['subject']]),
-            $topic['subject'],
-            true,
-        ];
-        $this->titles[] = $topic['subject'];
-
-        $id = $topic['forum_id'];
-        $activ = null;
-        while (true) {
-            $name = $fDesc[$id]['forum_name'];
-            $this->titles[] = $name;
-            $crumbs[] = [
-                $this->c->Router->link('Forum', ['id' => $id, 'name' => $name]),
-                $name, 
-                $activ,
-            ];
-            $activ = null;
-            if (! isset($fDesc[$id][0])) {
-                break;
+        if ($signs) {
+            // настройка парсера для подписей
+            $bbWList = $this->config['p_sig_bbcode'] == '1' ? $bbInfo['forSign'] : [];
+            $bbBList = $this->config['p_sig_img_tag'] == '1' ? [] : ['img'];
+            $parser->setAttr('isSign', true)
+                   ->setWhiteList($bbWList)
+                   ->setBlackList($bbBList);
+
+            foreach ($signs as &$cur) {
+                $parser->parse($this->censor($cur));
+                if ($this->config['o_smilies_sig'] == '1' && $user->showSmilies == '1') {
+                    $parser->detectSmilies();
+                }
+                $cur = $parser->getHtml();
             }
-            $id = $fDesc[$id][0];
+            unset($cur);
         }
-        $crumbs[] = [
-            $this->c->Router->link('Index'),
-            __('Index'),
-            null,
-        ];
+
+        $topic['subject'] = $this->censor($topic['subject']);
+        
+        $this->onlinePos = 'topic-' . $topic['id'];
 
         $this->data = [
-            'topic' => $topic,
-            'posts' => $posts,
+            'topic'    => $topic,
+            'posts'    => $posts,
+            'signs'    => $signs,
             'warnings' => $warnings,
-            'crumbs' => array_reverse($crumbs),
-            'topicName' => $topic['subject'],
-            'newPost' => $newPost,
-            'stickFP' => $stickFP,
-            'pages' => $this->c->Func->paginate($pages, $page, 'Topic', ['id' => $args['id'], 'name' => $topic['subject']]),
+            'crumbs'   => $this->getCrumbs(
+                ['Topic', ['id' => $topic['id'], 'name' => $topic['subject']]],
+                [$fDesc, $topic['forum_id']]
+            ),
+            'newPost'  => $newOn ? $this->c->Router->link('NewPost', ['id' => $topic['id']]) : $newOn,
+            'stickFP'  => $stickFP,
+            'pages'    => $this->c->Func->paginate($pages, $page, 'Topic', ['id' => $topic['id'], 'name' => $topic['subject']]),
+            'online'   => $this->getUsersOnlineInfo(),
+            'stats'    => null,
         ];
 
-        $this->canonical = $this->c->Router->link('Topic', ['id' => $args['id'], 'name' => $topic['subject'], 'page' => $page]);
+        $this->canonical = $this->c->Router->link('Topic', ['id' => $topic['id'], 'name' => $topic['subject'], 'page' => $page]);
+
+        if ($this->config['o_topic_views'] == '1') {
+            $vars = [
+                ':tid' => $topic['id'],
+            ];
+            $sql = 'UPDATE ::topics SET num_views=num_views+1 WHERE id=?i:tid';
+
+            $this->c->DB->query($sql, $vars);
+        }
 
         return $this;
     }

+ 2 - 2
app/Models/Pages/UsersTrait.php

@@ -47,10 +47,10 @@ trait UsersTrait
         $filetypes = array('jpg', 'gif', 'png');
     
         foreach ($filetypes as $type) {
-            $path = $this->c->DIR_PUBLIC . "/avatar/{$id}.{$type}";
+            $path = $this->c->DIR_PUBLIC . "/{$this->config['o_avatars_dir']}/{$id}.{$type}";
 
             if (file_exists($path) && getimagesize($path)) {
-                return $this->c->PUBLIC_URL . "/avatar/{$id}.{$type}";
+                return $this->c->PUBLIC_URL . "/{$this->config['o_avatars_dir']}/{$id}.{$type}";
             }
         }
 

+ 510 - 0
app/config/defaultBBCode.php

@@ -0,0 +1,510 @@
+<?php
+
+return [
+    ['tag' => 'ROOT',
+     'type' => 'block',
+     'handler' => function($body) {
+         $body = '<p>' . $body . '</p>';
+
+         // Replace any breaks next to paragraphs so our replace below catches them
+         $body = preg_replace('%(</?p>)(?:\s*?<br>){1,2}%', '$1', $body);
+         $body = preg_replace('%(?:<br>\s*?){1,2}(</?p>)%', '$1', $body);
+
+         // Remove any empty paragraph tags (inserted via quotes/lists/code/etc) which should be stripped
+         $body = str_replace('<p></p>', '', $body);
+
+         $body = preg_replace('%<br>\s*?<br>%', '</p><p>', $body);
+
+         $body = str_replace('<p><br>', '<br><p>', $body);
+         $body = str_replace('<br></p>', '</p><br>', $body);
+         $body = str_replace('<p></p>', '<br><br>', $body);
+
+         return $body;
+     },
+    ],
+    ['tag' => 'code',
+     'type' => 'block',
+     'recursive' => true,
+     'text only' => true,
+     'pre' => true,
+     'attrs' => [
+         'Def' => true,
+         'no attr' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $body = trim($body, "\n\r");
+         $class = substr_count($body, "\n") > 28 ? ' class="vscroll"' : '';
+         return '</p><div class="codebox"><pre' . $class . '><code>' . $body . '</code></pre></div><p>';
+     },
+    ],
+    ['tag' => 'b',
+     'handler' => function($body) {
+         return '<strong>' . $body . '</strong>';
+     },
+    ],
+    ['tag' => 'i',
+     'handler' => function($body) {
+         return '<em>' . $body . '</em>';
+     },
+    ],
+    ['tag' => 'em',
+     'handler' => function($body) {
+         return '<em>' . $body . '</em>';
+     },
+    ],
+    ['tag' => 'u',
+     'handler' => function($body) {
+         return '<span class="bbu">' . $body . '</span>';
+     },
+    ],
+    ['tag' => 's',
+     'handler' => function($body) {
+         return '<span class="bbs">' . $body . '</span>';
+     },
+    ],
+    ['tag' => 'del',
+     'handler' => function($body) {
+         return '<del>' . $body . '</del>';
+     },
+    ],
+    ['tag' => 'ins',
+     'handler' => function($body) {
+         return '<ins>' . $body . '</ins>';
+     },
+    ],
+    ['tag' => 'h',
+     'type' => 'h',
+     'handler' => function($body) {
+         return '</p><h5>' . $body . '</h5><p>';
+     },
+    ],
+    ['tag' => 'hr',
+     'type' => 'block',
+     'single' => true,
+     'handler' => function() {
+         return  '</p><hr><p>';
+     },
+    ],
+    ['tag' => 'color',
+     'self nesting' => true,
+     'attrs' => [
+         'Def' => [
+             'format' => '%^(?:\#(?:[\dA-Fa-f]{3}){1,2}|(?:aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|yellow|white))$%',
+         ],
+     ],
+     'handler' => function($body, $attrs) {
+         return '<span style="color:' . $attrs['Def'] . ';">' . $body . '</span>';
+     },
+    ],
+    ['tag' => 'colour',
+     'self nesting' => true,
+     'attrs' => [
+         'Def' => [
+             'format' => '%^(?:\#(?:[\dA-Fa-f]{3}){1,2}|(?:aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|yellow|white))$%',
+         ],
+     ],
+     'handler' => function($body, $attrs) {
+         return '<span style="color:' . $attrs['Def'] . ';">' . $body . '</span>';
+     },
+    ],
+    ['tag' => 'background',
+     'self nesting' => true,
+     'attrs' => [
+         'Def' => [
+             'format' => '%^(?:\#(?:[\dA-Fa-f]{3}){1,2}|(?:aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|yellow|white))$%',
+         ],
+     ],
+     'handler' => function($body, $attrs) {
+         return '<span style="background-color:' . $attrs['Def'] . ';">' . $body . '</span>';
+     },
+    ],
+    ['tag' => 'size',
+     'self nesting' => true,
+     'attrs' => [
+         'Def' => [
+             'format' => '%^[1-9]\d*(?:em|ex|pt|px|\%)?$%',
+         ],
+     ],
+     'handler' => function($body, $attrs) {
+         if (is_numeric($attrs['Def'])) {
+             $attrs['Def'] .= 'px';
+         }
+         return '<span style="font-size:' . $attrs['Def'] . ';">' . $body . '</span>';
+     },
+    ],
+    ['tag' => 'right',
+     'type' => 'block',
+     'handler' => function($body) {
+         return '</p><p style="text-align: right;">' . $body . '</p><p>';
+     },
+    ],
+    ['tag' => 'center',
+     'type' => 'block',
+     'handler' => function($body) {
+         return '</p><p style="text-align: center;">' . $body . '</p><p>';
+     },
+    ],
+    ['tag' => 'justify',
+     'type' => 'block',
+     'handler' => function($body) {
+         return '</p><p style="text-align: justify;">' . $body . '</p><p>';
+     },
+    ],
+    ['tag' => 'mono',
+     'handler' => function($body) {
+         return '<code>' . $body . '</code>';
+     },
+    ],
+    ['tag' => 'font',
+     'self nesting' => true,
+     'attrs' => [
+         'Def' => [
+             'format' => '%^[a-z\d, -]+$%i',
+         ],
+     ],
+     'handler' => function($body, $attrs) {
+         return '<span style="font-family:' . $attrs['Def'] . ';">' . $body . '</span>';
+     },
+    ],
+    ['tag' => 'email',
+     'type' => 'email',
+     'attrs' => [
+         'Def' => [
+             'format' => '%^[^\x00-\x1f\s]+?@[^\x00-\x1f\s]+$%',
+         ],
+         'no attr' => [
+             'body format' => '%^[^\x00-\x1f\s]+?@[^\x00-\x1f\s]+$%D',
+             'text only' => true,
+         ],
+     ],
+     'handler' => function($body, $attrs) {
+         if (empty($attrs['Def'])) {
+             return '<a href="mailto:' . $body . '">' . $body . '</a>';
+         } else {
+             return '<a href="mailto:' . $attrs['Def'] . '">' . $body . '</a>';
+         }
+     },
+    ],
+    ['tag' => '*',
+     'type' => 'block',
+     'self nesting' => true,
+     'parents' => ['list'],
+     'auto' => true,
+     'handler' => function($body) {
+         return '<li><p>' . $body . '</p></li>';
+     },
+    ],
+    ['tag' => 'list',
+     'type' => 'list',
+     'self nesting' => true,
+     'tags only' => true,
+     'attrs' => [
+         'Def' => true,
+         'no attr' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         if (!isset($attrs['Def'])) {
+             $attrs['Def'] = '*';
+         }
+
+         switch ($attrs['Def'][0]) {
+             case 'a':
+                 return '</p><ol class="alpha">' . $body . '</ol><p>';
+             case '1':
+                 return '</p><ol class="decimal">' . $body . '</ol><p>';
+             default:
+                 return '</p><ul>' . $body . '</ul><p>';
+         }
+     },
+    ],
+    ['tag' => 'after',
+     'type' => 'block',
+     'single' => true,
+     'attrs' => [
+         'Def' => [
+            'format' => '%^\d+$%',
+         ],
+     ],
+     'handler' => function($body, $attrs) {
+         $arr = array();
+         $sec = $attrs['Def'] % 60;
+         $min = ($attrs['Def'] / 60) % 60;
+         $hours = ($attrs['Def'] / 3600) % 24;
+         $days = (int) ($attrs['Def'] / 86400);
+         if ($days > 0) {
+             $arr[] = $days . __('After time d');
+         }
+         if ($hours > 0) {
+             $arr[] = $hours . __('After time H');
+         }
+         if ($min > 0) {
+             $arr[] = (($min < 10) ? '0' . $min : $min) . __('After time i');
+         }
+         if ($sec > 0) {
+             $arr[] = (($sec < 10) ? '0' . $sec : $sec) . __('After time s');
+         }
+
+         $attr = __('After time') . ' ' . implode(' ', $arr);
+
+         return '<span style="color: #808080"><em>' . $attr . ':</em></span><br />';
+     },
+    ],
+    ['tag' => 'quote',
+     'type' => 'block',
+     'self nesting' => true,
+     'attrs' => [
+         'Def' => true,
+         'no attr' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         if (isset($attrs['Def'])) {
+             $st = '</p><div class="quotebox"><cite>' . $attrs['Def'] .  ' ' . __('wrote') . '</cite><blockquote><div><p>';
+         } else {
+             $st = '</p><div class="quotebox"><blockquote><div><p>';
+         }
+
+         return $st . $body . '</p></div></blockquote></div><p>';
+     },
+    ],
+    ['tag' => 'spoiler',
+     'type' => 'block',
+     'self nesting' => true,
+     'attrs' => [
+         'Def' => true,
+         'no attr' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         if (isset($attrs['Def'])) {
+             $st = '</p><div class="quotebox" style="padding: 0px;"><div onclick="var e,d,c=this.parentNode,a=c.getElementsByTagName(\'div\')[1],b=this.getElementsByTagName(\'span\')[0];if(a.style.display!=\'\'){while(c.parentNode&&(!d||!e||d==e)){e=d;d=(window.getComputedStyle?getComputedStyle(c, null):c.currentStyle)[\'backgroundColor\'];if(d==\'transparent\'||d==\'rgba(0, 0, 0, 0)\')d=e;c=c.parentNode;}a.style.display=\'\';a.style.backgroundColor=d;b.innerHTML=\'&#9650;\';}else{a.style.display=\'none\';b.innerHTML=\'&#9660;\';}" style="font-weight: bold; cursor: pointer; font-size: 0.9em;"><span style="padding: 0 5px;">&#9660;</span>' . $attrs['Def'] . '</div><div style="padding: 6px; margin: 0; display: none;"><p>';
+         } else {
+             $st = '</p><div class="quotebox" style="padding: 0px;"><div onclick="var e,d,c=this.parentNode,a=c.getElementsByTagName(\'div\')[1],b=this.getElementsByTagName(\'span\')[0];if(a.style.display!=\'\'){while(c.parentNode&&(!d||!e||d==e)){e=d;d=(window.getComputedStyle?getComputedStyle(c, null):c.currentStyle)[\'backgroundColor\'];if(d==\'transparent\'||d==\'rgba(0, 0, 0, 0)\')d=e;c=c.parentNode;}a.style.display=\'\';a.style.backgroundColor=d;b.innerHTML=\'&#9650;\';}else{a.style.display=\'none\';b.innerHTML=\'&#9660;\';}" style="font-weight: bold; cursor: pointer; font-size: 0.9em;"><span style="padding: 0 5px;">&#9660;</span>' . __('Hidden text') . '</div><div style="padding: 6px; margin: 0; display: none;"><p>';
+         }
+
+         return $st . $body . '</p></div></div><p>';
+     },
+    ],
+    ['tag' => 'img',
+     'type' => 'img',
+     'parents' => ['inline', 'block', 'url'],
+     'text only' => true,
+     'attrs' => [
+         'Def' => [
+             'body format' => '%^(?:(?:ht|f)tps?://[^\x00-\x1f\s<"]+|data:image/[a-z]+;base64,(?:[a-zA-Z\d/\+\=]+))$%D'
+         ],
+         'no attr' => [
+             'body format' => '%^(?:(?:ht|f)tps?://[^\x00-\x1f\s<"]+|data:image/[a-z]+;base64,(?:[a-zA-Z\d/\+\=]+))$%D'
+         ],
+     ],
+     'handler' => function($body, $attrs, $parser) {
+         if (! isset($attrs['Def'])) {
+             $attrs['Def'] = (substr($body, 0, 11) === 'data:image/') ? 'base64' : basename($body);
+         }
+
+         // тег в подписи
+         if ($parser->attr('isSign')) {
+            if ($parser->attr('showImgSign')) {
+                return '<img src="' . $body . '" alt="' . $attrs['Def'] . '" class="sigimage" />';
+            }
+         } else {
+         // тег в теле сообщения
+            if ($parser->attr('showImg')) {
+                return '<span class="postimg"><img src="' . $body . '" alt="' . $attrs['Def'] . '" /></span>';
+            }
+         }
+
+         return '<a href="' . $body . '" rel="nofollow">&lt;' . __('Image link') . ' - ' . $attrs['Def'] . '&gt;</a>';
+     },
+    ],
+    ['tag' => 'url',
+     'type' => 'url',
+     'parents' => ['inline', 'block'],
+     'attrs' => [
+         'Def' => [
+             'format' => '%^[^\x00-\x1f]+$%',
+         ],
+         'no attr' => [
+             'body format' => '%^[^\x00-\x1f]+$%D',
+         ],
+     ],
+     'handler' => function($body, $attrs, $parser) {
+         if (isset($attrs['Def'])) {
+             $url = $attrs['Def'];
+         } else {
+             $url = $body;
+             // возможно внутри была картинка, которая отображается как ссылка
+             if (preg_match('%^<a href=".++(?<=</a>)$%D', $url)) {
+                 return $url;
+             }
+             // возможно внутри картинка
+             if (preg_match('%<img src="([^"]+)"%', $url, $match)) {
+                 $url = $match[1];
+             }
+         }
+
+         $fUrl = str_replace(array(' ', '\'', '`', '"'), array('%20', '', '', ''), $url);
+
+         if (strpos($url, 'www.') === 0) {
+             $fUrl = 'http://'.$fUrl;
+         } else if (strpos($url, 'ftp.') === 0) {
+             $fUrl = 'ftp://'.$fUrl;
+         } else if (strpos($url, '/') === 0) {
+             $fUrl = $parser->attr('baseUrl') . $fUrl;
+         } else if (!preg_match('%^([a-z0-9]{3,6})://%', $url)) {
+             $fUrl = 'http://'.$fUrl;
+         }
+
+         if ($url === $body) {
+             $url = htmlspecialchars_decode($url, ENT_QUOTES);
+             $url = mb_strlen($url, 'UTF-8') > 55 ? mb_substr($url, 0, 39, 'UTF-8') . ' … ' . mb_substr($url, -10, null, 'UTF-8') : $url;
+             $body = $parser->e($url);
+         }
+
+         return '<a href="' . $fUrl . '" rel="nofollow">' . $body . '</a>';
+     },
+    ],
+    ['tag' => 'table',
+     'type' => 'table',
+     'tags only' => true,
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+         'align' => true,
+         'background' => true,
+         'bgcolor' => true,
+         'border' => true,
+         'bordercolor' => true,
+         'cellpadding' => true,
+         'cellspacing' => true,
+         'frame' => true,
+         'rules' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '</p><table' . $attr . '>' . $body . '</table><p>';
+     },
+    ],
+    ['tag' => 'caption',
+     'type' => 'block',
+     'parents' => ['table'],
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<caption' . $attr . '><p>' . $body . '</p></caption>';
+     },
+    ],
+    ['tag' => 'thead',
+     'type' => 't',
+     'parents' => ['table'],
+     'tags only' => true,
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<thead' . $attr . '>' . $body . '</thead>';
+     },
+    ],
+    ['tag' => 'tbody',
+     'type' => 't',
+     'parents' => ['table'],
+     'tags only' => true,
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<tbody' . $attr . '>' . $body . '</tbody>';
+     },
+    ],
+    ['tag' => 'tfoot',
+     'type' => 't',
+     'parents' => ['table'],
+     'tags only' => true,
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<tfoot' . $attr . '>' . $body . '</tfoot>';
+     },
+    ],
+    ['tag' => 'tr',
+     'type' => 'tr',
+     'parents' => ['table', 't'],
+     'tags only' => true,
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<tr' . $attr . '>' . $body . '</tr>';
+     },
+    ],
+    ['tag' => 'th',
+     'type' => 'block',
+     'parents' => ['tr'],
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+         'colspan' => true,
+         'rowspan' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<th' . $attr . '><p>' . $body . '</p></th>';
+     },
+    ],
+    ['tag' => 'td',
+     'type' => 'block',
+     'parents' => ['tr'],
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+         'colspan' => true,
+         'rowspan' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<td' . $attr . '><p>' . $body . '</p></td>';
+     },
+    ],
+];

+ 5 - 8
app/lang/English/index.po

@@ -24,12 +24,6 @@ msgstr "Board is empty."
 msgid "Newest user"
 msgstr "Newest registered user:"
 
-msgid "Users online"
-msgstr "Registered users online:"
-
-msgid "Guests online"
-msgstr "guests:"
-
 msgid "No of users"
 msgstr "Total number of registered users:"
 
@@ -39,10 +33,10 @@ msgstr "Total number of topics:"
 msgid "No of posts"
 msgstr "Total number of posts:"
 
-msgid "Online"
+msgid "Online users"
 msgstr "Online:"
 
-msgid "Board info"
+msgid "Stats info"
 msgstr "Board information"
 
 msgid "Board stats"
@@ -53,3 +47,6 @@ msgstr "Maximum online users (%1$s) was be there %2$s"
 
 msgid "User info"
 msgstr "User information"
+
+msgid "Visitors online"
+msgstr "Registered users online: <strong>%1$s</strong>, guests: <strong>%2$s</strong>."

+ 10 - 4
app/lang/English/topic.po

@@ -95,8 +95,14 @@ msgstr "Warnings:"
 msgid "Reply"
 msgstr "Reply"
 
-msgid "Users online"
-msgstr "Registered users online in this topic: %s"
+msgid "User info"
+msgstr "User information"
 
-msgid "Guests online"
-msgstr "guests: %s"
+msgid "Visitors online"
+msgstr "Registered users online in this topic: <strong>%1$s</strong>, guests: <strong>%2$s</strong>."
+
+msgid "Online users"
+msgstr "Online:"
+
+msgid "Stats info"
+msgstr "Topic information"

+ 5 - 8
app/lang/Russian/index.po

@@ -24,12 +24,6 @@ msgstr "Форум пуст."
 msgid "Newest user"
 msgstr "Новичок:"
 
-msgid "Users online"
-msgstr "Сейчас пользователей:"
-
-msgid "Guests online"
-msgstr "гостей:"
-
 msgid "No of users"
 msgstr "Всего пользователей:"
 
@@ -39,10 +33,10 @@ msgstr "Всего тем:"
 msgid "No of posts"
 msgstr "Всего сообщений:"
 
-msgid "Online"
+msgid "Online users"
 msgstr "Активны:"
 
-msgid "Board info"
+msgid "Stats info"
 msgstr "Информация о форуме"
 
 msgid "Board stats"
@@ -53,3 +47,6 @@ msgstr "Больше всего онлайн посетителей (%1$s) зд
 
 msgid "User info"
 msgstr "Информация о пользователях"
+
+msgid "Visitors online"
+msgstr "Сейчас пользователей: <strong>%1$s</strong>, гостей: <strong>%2$s</strong>."

+ 10 - 4
app/lang/Russian/topic.po

@@ -96,8 +96,14 @@ msgstr "Предупреждений:"
 msgid "Reply"
 msgstr "Ответить"
 
-msgid "Users online"
-msgstr "Сейчас в этой теме пользователей: %s"
+msgid "User info"
+msgstr "Информация о пользователях"
 
-msgid "Guests online"
-msgstr "гостей: %s"
+msgid "Visitors online"
+msgstr "Сейчас в этой теме пользователей: <strong>%1$s</strong>, гостей: <strong>%2$s</strong>."
+
+msgid "Online users"
+msgstr "Активны:"
+
+msgid "Stats info"
+msgstr "Информация о теме"

+ 1 - 35
app/templates/index.tpl

@@ -22,38 +22,4 @@
       <h2>{!! __('Empty board') !!}</h2>
     </section>
 @endif
-    <section class="f-stats">
-      <h2>{!! __('Board info') !!}</h2>
-      <div class="clearfix">
-        <dl class="right">
-          <dt>{!! __('Board stats') !!}</dt>
-          <dd>{!! __('No of users') !!} <strong>{!! $stats['total_users'] !!}</strong></dd>
-          <dd>{!! __('No of topics') !!} <strong>{!! $stats['total_topics'] !!}</strong></dd>
-          <dd>{!! __('No of posts') !!} <strong>{!! $stats['total_posts'] !!}</strong></dd>
-        </dl>
-        <dl class="left">
-          <dt>{!! __('User info') !!}</dt>
-@if(is_string($stats['newest_user']))
-          <dd>{!! __('Newest user')  !!} {{ $stats['newest_user'] }}</dd>
-@else
-          <dd>{!! __('Newest user')  !!} <a href="{!! $stats['newest_user'][0] !!}">{{ $stats['newest_user'][1] }}</a></dd>
-@endif
-@if($online)
-          <dd>{!! __('Users online') !!} <strong>{!! $online['number_of_users'] !!}</strong>, {!! __('Guests online') !!} <strong>{!! $online['number_of_guests'] !!}</strong>.</dd>
-          <dd>{!! __('Most online', $online['max'], $online['max_time']) !!}</dd>
-@endif
-        </dl>
-@if($online && $online['list'])
-        <dl class="f-inline f-onlinelist"><!-- inline -->
-          <dt>{!! __('Online') !!}</dt>
-@foreach($online['list'] as $cur)
-@if(is_string($cur))
-          <dd>{{ $cur }}</dd>
-@else
-          <dd><a href="{!! $cur[0] !!}">{{ $cur[1] }}</a></dd>
-@endif
-@endforeach
-        </dl><!-- endinline -->
-@endif
-      </div>
-    </section>
+@include('layouts/stats')

+ 1 - 1
app/templates/layouts/redirect.tpl

@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="{!! __('lang_identifier') !!} dir="{!! __('lang_direction') !!}">
+<html lang="{!! __('lang_identifier') !!}" dir="{!! __('lang_direction') !!}">
 <head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">

+ 39 - 0
app/templates/layouts/stats.tpl

@@ -0,0 +1,39 @@
+    <section class="f-stats">
+      <h2>{!! __('Stats info') !!}</h2>
+      <div class="clearfix">
+@if($stats)
+        <dl class="right">
+          <dt>{!! __('Board stats') !!}</dt>
+          <dd>{!! __('No of users') !!} <strong>{!! $stats['total_users'] !!}</strong></dd>
+          <dd>{!! __('No of topics') !!} <strong>{!! $stats['total_topics'] !!}</strong></dd>
+          <dd>{!! __('No of posts') !!} <strong>{!! $stats['total_posts'] !!}</strong></dd>
+        </dl>
+@endif
+        <dl class="left">
+          <dt>{!! __('User info') !!}</dt>
+@if($stats && is_string($stats['newest_user']))
+          <dd>{!! __('Newest user')  !!} {{ $stats['newest_user'] }}</dd>
+@elseif($stats)
+          <dd>{!! __('Newest user')  !!} <a href="{!! $stats['newest_user'][0] !!}">{{ $stats['newest_user'][1] }}</a></dd>
+@endif
+@if($online)
+          <dd>{!! __('Visitors online', $online['number_of_users'], $online['number_of_guests']) !!}</dd>
+@endif
+@if($stats)
+          <dd>{!! __('Most online', $online['max'], $online['max_time']) !!}</dd>
+@endif
+        </dl>
+@if($online && $online['list'])
+        <dl class="f-inline f-onlinelist"><!-- inline -->
+          <dt>{!! __('Online users') !!}</dt>
+@foreach($online['list'] as $cur)
+@if(is_string($cur))
+          <dd>{{ $cur }}</dd>
+@else
+          <dd><a href="{!! $cur[0] !!}">{{ $cur[1] }}</a></dd>
+@endif
+@endforeach
+        </dl><!-- endinline -->
+@endif
+      </div>
+    </section>

+ 18 - 7
app/templates/topic.tpl

@@ -48,14 +48,14 @@
 @endif
     </div>
     <section class="f-main f-topic">
-      <h2>{{ $topicName }}</h2>
+      <h2>{{ $topic['subject'] }}</h2>
 @foreach($posts as $post)
       <article id="p{!! $post['id'] !!}" class="f-post{!! $post['poster_gender'].$post['poster_online'] !!} clearfix">
-        <div class="f-post-header clearfix">
-          <h3>{{ $topicName }} - #{!! $post['post_number'] !!}</h3>
+        <header class="f-post-header clearfix">
+          <h3>{{ $topic['subject'] }} - #{!! $post['post_number'] !!}</h3>
           <span class="left"><time datetime="{{ $post['posted_utc'] }}">{{ $post['posted'] }}</time></span>
           <span class="right"><a href="{!! $post['link'] !!}" rel="bookmark">#{!! $post['post_number'] !!}</a></span>
-        </div>
+        </header>
         <div class="f-post-body clearfix">
           <address class="f-post-left clearfix">
             <ul class="f-user-info">
@@ -84,9 +84,17 @@
             </ul>
 @endif
           </address>
-
+          <div class="f-post-right f-post-main">
+            {!! $post['message'] !!}
+          </div>
+@if(isset($signs[$post['poster_id']]))
+          <div class="f-post-right f-post-signature">
+            <hr>
+            {!! $signs[$post['poster_id']] !!}
+          </div>
+@endif
         </div>
-        <div class="f-post-footer clearfix">
+        <footer class="f-post-footer clearfix">
           <div class="f-post-left">
             <span></span>
           </div>
@@ -99,7 +107,7 @@
             </ul>
           </div>
 @endif
-        </div>
+        </footer>
       </article>
 @endforeach
     </section>
@@ -112,3 +120,6 @@
 @endif
 @yield('crumbs')
     </div>
+@if($online)
+@include('layouts/stats')
+@endif

+ 2 - 1
composer.json

@@ -18,6 +18,7 @@
     },
     "require": {
         "php": ">=5.6.0",
-        "artoodetoo/dirk": "dev-master"
+        "artoodetoo/dirk": "dev-master",
+        "MioVisman/Parserus": "^0.9.1"
     }
 }

+ 44 - 2
composer.lock

@@ -4,8 +4,8 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "hash": "ad22a23d7b225fa300553d8d0dcdaeb9",
-    "content-hash": "2eea8744cdbc34c8408e6d137176a8df",
+    "hash": "f222f403c8d48f00cb5e3e37f680ebcb",
+    "content-hash": "b8c1ffae094a89502ad8a3c60385d4b8",
     "packages": [
         {
             "name": "artoodetoo/dirk",
@@ -52,6 +52,48 @@
                 "views"
             ],
             "time": "2017-01-10 21:38:22"
+        },
+        {
+            "name": "miovisman/parserus",
+            "version": "0.9.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/MioVisman/Parserus.git",
+                "reference": "d039178d46c989ebb8eb89970b52eb6ce927c817"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/MioVisman/Parserus/zipball/d039178d46c989ebb8eb89970b52eb6ce927c817",
+                "reference": "d039178d46c989ebb8eb89970b52eb6ce927c817",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.4.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "Parserus": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Visman",
+                    "email": "mio.visman@yandex.ru",
+                    "homepage": "https://github.com/MioVisman"
+                }
+            ],
+            "description": "BBCode parser.",
+            "homepage": "https://github.com/MioVisman/Parserus",
+            "keywords": [
+                "bbcode",
+                "parser"
+            ],
+            "time": "2016-11-26 06:56:51"
         }
     ],
     "packages-dev": [],

+ 1 - 1
public/.htaccess

@@ -4,7 +4,7 @@ AddDefaultCharset UTF-8
   RewriteEngine On
   #RewriteBase /
   RewriteCond %{REQUEST_FILENAME} !-f
-  RewriteCond %{REQUEST_FILENAME} !-d
+  #RewriteCond %{REQUEST_FILENAME} !-d
   #RewriteCond %{REQUEST_URI} !/public/.*\.\w+$
   RewriteCond %{REQUEST_URI} !favicon\.ico
   RewriteRule . index.php [L]

BIN
public/img/sm/big_smile.png


BIN
public/img/sm/cool.png


BIN
public/img/sm/hmm.png


BIN
public/img/sm/lol.png


BIN
public/img/sm/mad.png


BIN
public/img/sm/neutral.png


BIN
public/img/sm/roll.png


BIN
public/img/sm/sad.png


BIN
public/img/sm/smile.png


BIN
public/img/sm/tongue.png


BIN
public/img/sm/wink.png


BIN
public/img/sm/yikes.png


+ 5 - 0
public/style/ForkBB/style.css

@@ -1215,6 +1215,11 @@ li + li .f-btn {
   clear: left;
 }
 
+.f-post-signature {
+  font-size: 0.875rem;
+  opacity: 0.5;
+}
+
 @media screen and (min-width: 50rem) {
   .f-post {
     background-color: #F8F4E3;

+ 1 - 0
vendor/composer/autoload_namespaces.php

@@ -6,4 +6,5 @@ $vendorDir = dirname(dirname(__FILE__));
 $baseDir = dirname($vendorDir);
 
 return array(
+    'Parserus' => array($vendorDir . '/miovisman/parserus'),
 );

+ 11 - 0
vendor/composer/autoload_static.php

@@ -28,11 +28,22 @@ class ComposerStaticInit90ad93c7251d4f60daa9e545879c49e7
         ),
     );
 
+    public static $prefixesPsr0 = array (
+        'P' => 
+        array (
+            'Parserus' => 
+            array (
+                0 => __DIR__ . '/..' . '/miovisman/parserus',
+            ),
+        ),
+    );
+
     public static function getInitializer(ClassLoader $loader)
     {
         return \Closure::bind(function () use ($loader) {
             $loader->prefixLengthsPsr4 = ComposerStaticInit90ad93c7251d4f60daa9e545879c49e7::$prefixLengthsPsr4;
             $loader->prefixDirsPsr4 = ComposerStaticInit90ad93c7251d4f60daa9e545879c49e7::$prefixDirsPsr4;
+            $loader->prefixesPsr0 = ComposerStaticInit90ad93c7251d4f60daa9e545879c49e7::$prefixesPsr0;
 
         }, null, ClassLoader::class);
     }

+ 44 - 0
vendor/composer/installed.json

@@ -46,5 +46,49 @@
             "templating",
             "views"
         ]
+    },
+    {
+        "name": "miovisman/parserus",
+        "version": "0.9.1",
+        "version_normalized": "0.9.1.0",
+        "source": {
+            "type": "git",
+            "url": "https://github.com/MioVisman/Parserus.git",
+            "reference": "d039178d46c989ebb8eb89970b52eb6ce927c817"
+        },
+        "dist": {
+            "type": "zip",
+            "url": "https://api.github.com/repos/MioVisman/Parserus/zipball/d039178d46c989ebb8eb89970b52eb6ce927c817",
+            "reference": "d039178d46c989ebb8eb89970b52eb6ce927c817",
+            "shasum": ""
+        },
+        "require": {
+            "php": ">=5.4.0"
+        },
+        "time": "2016-11-26 06:56:51",
+        "type": "library",
+        "installation-source": "dist",
+        "autoload": {
+            "psr-0": {
+                "Parserus": ""
+            }
+        },
+        "notification-url": "https://packagist.org/downloads/",
+        "license": [
+            "MIT"
+        ],
+        "authors": [
+            {
+                "name": "Visman",
+                "email": "mio.visman@yandex.ru",
+                "homepage": "https://github.com/MioVisman"
+            }
+        ],
+        "description": "BBCode parser.",
+        "homepage": "https://github.com/MioVisman/Parserus",
+        "keywords": [
+            "bbcode",
+            "parser"
+        ]
     }
 ]

+ 21 - 0
vendor/miovisman/parserus/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016 Visman
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 1222 - 0
vendor/miovisman/parserus/Parserus.php

@@ -0,0 +1,1222 @@
+<?php
+
+/**
+ * @copyright  Copyright (c) 2016 Visman. All rights reserved.
+ * @author     Visman <mio.visman@yandex.ru>
+ * @link       https://github.com/MioVisman/Parserus
+ * @license    https://opensource.org/licenses/MIT The MIT License (MIT)
+ */
+
+class Parserus
+{
+    /**
+     * Массив дерева тегов построенный методом parse()
+     * @var array
+     */
+    protected $data;
+
+    /**
+     * Индекс последнего элемента из массива data
+     * @var int
+     */
+    protected $dataId;
+
+    /**
+     * Индекс текущего элемента дерева из массива data
+     * @var int
+     */
+    protected $curId;
+
+    /**
+     * Битовая маска флагов для функции htmlspecialchars()
+     * @var int
+     */
+    protected $eFlags;
+
+    /**
+     * Массив искомых значений для замены при преобразовании текста в HTML
+     * @var array
+     */
+    protected $tSearch = ["\n", "\t", '  ', '  '];
+
+    /**
+     * Массив значений замены при преобразовании текста в HTML
+     * @var array
+     */
+    protected $tRepl;
+
+    /**
+     * Массив разрешенных тегов. Если null, то все теги из bbcodes разрешены
+     * @var array|null
+     */
+    protected $whiteList = null;
+
+    /**
+     * Массив запрещенных тегов. Если null, то все теги из bbcodes разрешены
+     * @var array|null
+     */
+    protected $blackList = null;
+
+    /**
+     * Ассоциативный массив bb-кодов
+     * @var array
+     */
+    protected $bbcodes = [];
+
+    /**
+     * Ассоциативный массив переменных, которые можно использовать в bb-кодах
+     * @var array
+     */
+    protected $attrs = [];
+
+    /**
+     * Ассоциативный массив смайлов
+     * @var array
+     */
+    protected $smilies = [];
+
+    /**
+     * Паттерн для поиска смайлов в тексте при получении HTML
+     * @var string|null
+     */
+    protected $smPattern = null;
+
+    /**
+     * Флаг необходимости обработки смайлов при получении HTML
+     * @var bool
+     */
+    protected $smOn = false;
+
+    /**
+     * Шаблон подстановки при обработке смайлов
+     * Например: <img src="{url}" alt="{alt}">
+     * @var string
+     */
+    protected $smTpl = '';
+
+    /**
+     * Имя тега под которым идет отображение смайлов
+     * @var string
+     */
+    protected $smTag = '';
+
+    /**
+     * Список тегов в которых не нужно отображать смайлы
+     * @var array
+     */
+    protected $smBL = [];
+
+    /**
+     * Массив ошибок полученных при отработке метода parse()
+     * @var array
+     */
+    protected $errors = [];
+
+    /**
+     * Флаг строгого режима поиска ошибок
+     * Нужен, например, для проверки атрибутов тегов при получении текста от пользователя
+     * @var bool
+     */
+    protected $strict = false;
+
+    /**
+     * Конструктор
+     *
+     * @param int $flag Один из флагов ENT_HTML401, ENT_XML1, ENT_XHTML, ENT_HTML5
+     */
+    public function __construct($flag = ENT_HTML5)
+    {
+        if (! in_array($flag, [ENT_HTML401, ENT_XML1, ENT_XHTML, ENT_HTML5])) {
+            $flag = ENT_HTML5;
+        }
+        $this->eFlags = $flag | ENT_QUOTES | ENT_SUBSTITUTE;
+        $this->tRepl = in_array($flag, [ENT_HTML5, ENT_HTML401])
+                     ? ['<br>',   '&nbsp; &nbsp; ', '&nbsp; ', ' &nbsp;']
+                     : ['<br />', '&#160; &#160; ', '&#160; ', ' &#160;'];
+    }
+
+    /**
+     * Метод добавляет один bb-код
+     *
+     * @param  array    $bb   Массив описания bb-кода
+     * @return Parserus $this
+     */
+    public function addBBCode(array $bb)
+    {
+        $res = [
+            'type' => 'inline',
+            'parents' => ['inline' => 1, 'block' => 2],
+            'auto' => true,
+            'self nesting' => false,
+        ];
+
+        if ($bb['tag'] === 'ROOT') {
+            $tag = 'ROOT';
+        } else {
+            $tag = strtolower($bb['tag']);
+        }
+
+        if (isset($bb['type'])) {
+            $res['type'] = $bb['type'];
+
+            if ($bb['type'] !== 'inline') {
+                $res['parents'] = ['block' => 1];
+                $res['auto'] = false;
+            }
+        }
+
+        if (isset($bb['parents'])) {
+            $res['parents'] = array_flip($bb['parents']);
+        }
+
+        if (isset($bb['auto'])) {
+            $res['auto'] = (bool) $bb['auto'];
+        }
+
+        if (isset($bb['self nesting'])) {
+            $res['self nesting'] = (bool) $bb['self nesting'];
+        }
+
+        if (isset($bb['recursive'])) {
+            $res['recursive'] = true;
+        }
+
+        if (isset($bb['text only'])) {
+            $res['text only'] = true;
+        }
+
+        if (isset($bb['tags only'])) {
+            $res['tags only'] = true;
+        }
+
+        if (isset($bb['single'])) {
+            $res['single'] = true;
+        }
+
+        if (isset($bb['pre'])) {
+            $res['pre'] = true;
+        }
+
+        $res['handler'] = isset($bb['handler']) ? $bb['handler'] : null;
+
+        $required = [];
+        $attrs = [];
+        $other = false;
+
+        if (! isset($bb['attrs'])) {
+            $cur = [];
+
+            if (isset($bb['body format'])) {
+                $cur['body format'] = $bb['body format'];
+            }
+            if (isset($bb['text only'])) {
+                $cur['text only'] = true;
+            }
+
+            $attrs['no attr'] = $cur;
+        } else {
+            foreach ($bb['attrs'] as $attr => $cur) {
+                if (! is_array($cur)) {
+                    $cur = [];
+                }
+
+                if (isset($bb['text only'])) {
+                    $cur['text only'] = true;
+                }
+
+                $attrs[$attr] = $cur;
+
+                if (isset($cur['required'])) {
+                    $required[] = $attr;
+                }
+
+                if ($attr !== 'Def' && $attr !== 'no attr') {
+                    $other = true;
+                }
+            }
+        }
+
+        $res['attrs'] = $attrs;
+        $res['required'] = $required;
+        $res['other'] = $other;
+
+        $this->bbcodes[$tag] = $res;
+        return $this;
+    }
+
+    /**
+     * Метод задает массив bb-кодов
+     *
+     * @param  array    $bbcodes Массив описаний bb-кодов
+     * @return Parserus $this
+     */
+    public function setBBCodes(array $bbcodes)
+    {
+        $this->bbcodes = [];
+
+        foreach ($bbcodes as $bb) {
+            $this->addBBCode($bb);
+        }
+
+        $this->defaultROOT();
+        return $this;
+    }
+
+    /**
+     * Метод устанавливает тег ROOT при его отсутствии
+     */
+    protected function defaultROOT()
+    {
+        if (! isset($this->bbcodes['ROOT'])) {
+            $this->addBBCode(['tag' => 'ROOT', 'type' => 'block']);
+        }
+    }
+
+    /**
+     * Метод задает массив смайлов
+     *
+     * @param  array    $smilies Ассоциативный массив смайлов
+     * @return Parserus $this
+     */
+    public function setSmilies(array $smilies)
+    {
+        $this->smilies = $smilies;
+        $this->createSmPattern();
+        return $this;
+    }
+
+    /**
+     * Метод генерирует паттерн для поиска смайлов в тексте
+     */
+    protected function createSmPattern()
+    {
+        if (empty($this->smilies)) {
+            $this->smPattern = null;
+            return;
+        }
+
+        $arr = array_keys($this->smilies);
+        sort($arr);
+        $arr[] = '  ';
+
+        $symbol = '';
+        $pattern = '';
+        $quote = '';
+        $sub = [];
+
+        foreach ($arr as $val) {
+            if (preg_match('%^(.)(.+)%u', $val, $match)) {
+                if ($symbol === $match[1]) {
+                    $sub[] = preg_quote($match[2], '%');
+                } else {
+                    if (count($sub) > 1) {
+                        $pattern .= $quote . preg_quote($symbol, '%') . '(?:' . implode('|', $sub) . ')';
+                        $quote = '|';
+                    } else if (count($sub) == 1) {
+                        $pattern .= $quote . preg_quote($symbol, '%') . $sub[0];
+                        $quote = '|';
+                    }
+                    $symbol = $match[1];
+                    $sub = [preg_quote($match[2], '%')];
+                }
+            }
+        }
+
+        $this->smPattern = '%(?<=\s|^)(?:' . $pattern . ')(?![\p{L}\p{N}])%u';
+    }
+
+    /**
+     * Метод устанавливает шаблон для отображения смайлов
+     *
+     * @param  string   $tpl  Строка шаблона, например: <img src="{url}" alt="{alt}">
+     * @param  string   $tag  Имя тега под которым идет отображение смайлов
+     * @param  array    $bl   Список тегов в которых не нужно отображать смайлы
+     * @return Parserus $this
+     */
+    public function setSmTpl($tpl, $tag = 'img', array $bl = ['url'])
+    {
+        $this->smTpl = $tpl;
+        $this->smTag = $tag;
+        $this->smBL = array_flip($bl);
+        return $this;
+    }
+
+    /**
+     * Метод включает (если есть возможность) отображение смайлов на текущем дереве тегов
+     *
+     * @return Parserus $this
+     */
+    public function detectSmilies()
+    {
+        $this->smOn = null !== $this->smPattern && isset($this->bbcodes[$this->smTag]);
+        return $this;
+    }
+
+    /**
+     * Метод устанавливает список разрешенных bb-кодов
+     *
+     * @param  mixed    $list Массив bb-кодов, null и т.д.
+     * @return Parserus $this
+     */
+    public function setWhiteList($list = null)
+    {
+        $this->whiteList = is_array($list) ? $list : null;
+        return $this;
+    }
+
+    /**
+     * Метод устанавливает список запрещенных bb-кодов
+     *
+     * @param  mixed    $list Массив bb-кодов, null и т.д.
+     * @return Parserus $this
+     */
+    public function setBlackList($list = null)
+    {
+        $this->blackList = ! empty($list) && is_array($list) ? $list : null;
+        return $this;
+    }
+
+    /**
+     * Метод задает значение переменной для возможного использования в bb-кодах
+     *
+     * @param  string   $name Имя переменной
+     * @param  mixed    $val  Значение переменной
+     * @return Parserus $this
+     */
+    public function setAttr($name, $val)
+    {
+        $this->attrs[$name] = $val;
+        return $this;
+    }
+
+    /**
+     * Метод для получения значения переменной
+     *
+     * @param  string     $name Имя переменной
+     * @return mixed|null       Значение переменной или null, если переменная не была задана ранее
+     */
+    public function attr($name)
+    {
+        return isset($this->attrs[$name]) ? $this->attrs[$name] : null;
+    }
+
+    /**
+     * Метод добавляет новый тег в дерево тегов
+     *
+     * @param  string $tag      Имя тега
+     * @param  int    $parentId Указатель на родителя
+     * @param  array  $attrs    Массив атрибутов тега
+     * @param  bool   $textOnly Флаг. Если true, то в теле только текст
+     * @return int              Указатель на данный тег
+     */
+    protected function addTagNode($tag, $parentId = null, $attrs = [], $textOnly = false)
+    {
+        $this->data[++$this->dataId] = [
+            'tag' => $tag,
+            'parent' => $parentId,
+            'children' => [],
+            'attrs' => $attrs,
+        ];
+
+        if ($textOnly) {
+            $this->data[$this->dataId]['text only'] = true;
+        }
+
+        if (null !== $parentId) {
+            $this->data[$parentId]['children'][] = $this->dataId;
+        }
+
+        return $this->dataId;
+    }
+
+    /**
+     * Метод добавляет текстовый узел в дерево тегов
+     *
+     * @param  string $text     Текст
+     * @param  int    $parentId Указатель на родителя
+     * @return string           Пустая строка
+     */
+    protected function addTextNode($text, $parentId)
+    {
+        if (isset($text[0])) {
+
+            $this->data[++$this->dataId] = [
+                'text' => $text,
+                'parent' => $parentId,
+            ];
+
+            $this->data[$parentId]['children'][] = $this->dataId;
+        }
+
+        return '';
+    }
+
+    /**
+     * Метод нормализует содержимое атрибута
+     *
+     * @param  string $attr Содержимое атрибута полученное из регулярного выражения
+     * @return string
+     */
+    protected function getNormAttr($attr)
+    {
+        // удаление крайних кавычек
+        if (isset($attr[1])
+            && $attr[0] === $attr[strlen($attr) - 1]
+            && ($attr[0] === '"' || $attr[0] === '\'')
+        ) {
+            return substr($attr, 1, -1);
+        }
+
+        return $attr;
+    }
+
+    /**
+     * Метод выделяет все атрибуты с их содержимым для обрабатываемого тега
+     *
+     * @param  string           $tag  Имя обрабатываемого тега
+     * @param  string           $type "Тип атрибутов" = ' ', '=' или ']'
+     * @param  string           $text Текст из которого выделяются атрибуты
+     * @return null|array
+     */
+    protected function parseAttrs($tag, $type, $text)
+    {
+        $attrs = [];
+        $tagText = '';
+
+        if ($type === '=') {
+            $pattern = '%^(?!\x20)
+                ("[^\x00-\x1f"]*(?:"+(?!\x20*+\]|\x20++[a-z-]{2,15}=)[^\x00-\x1f"]*)*"
+                |\'[^\x00-\x1f\']*(?:\'+(?!\x20*+\]|\x20++[a-z-]{2,15}=)[^\x00-\x1f\']*)*\'
+                |[^\x00-\x20\]]+(?:\x20++(?!\]|[a-z-]{2,15}=)[^\x00-\x20\]]+)*)
+                \x20*
+                (\]|\x20(?=[a-z-]{2,15}=))%x';
+
+            $match = preg_split($pattern, $text, 2, PREG_SPLIT_DELIM_CAPTURE);
+
+            if (! isset($match[1])) {
+                return null;
+            }
+
+            $type = $match[2];
+            $tagText .= $match[1] . $match[2];
+            $text = $match[3];
+
+            $tmp = $this->getNormAttr($match[1]);
+            if (isset($tmp[0])) {
+                $attrs['Def'] = $tmp;
+
+                // в теге не может быть первичного атрибута
+                if ($this->strict
+                    && ! isset($this->bbcodes[$tag]['attrs']['Def'])
+                ) {
+                    $this->errors[] = [7, $tag];
+                    return null;
+                }
+            }
+        }
+
+        if ($type !== ']') {
+            $pattern = '%^\x20*+([a-z-]{2,15})
+                =(?!\x20)
+                ("[^\x00-\x1f"]*(?:"+(?!\x20*+\]|\x20++[a-z-]{2,15}=)[^\x00-\x1f"]*)*"
+                |\'[^\x00-\x1f\']*(?:\'+(?!\x20*+\]|\x20++[a-z-]{2,15}=)[^\x00-\x1f\']*)*\'
+                |[^\x00-\x20\]]+(?:\x20++(?!\]|[a-z-]{2,15}=)[^\x00-\x20\]]+)*)
+                \x20*
+                (\]|\x20(?=[a-z-]{2,15}=))%x';
+
+            do {
+                $match = preg_split($pattern, $text, 2, PREG_SPLIT_DELIM_CAPTURE);
+
+                if (! isset($match[1])) {
+                    return null;
+                }
+
+                $tagText .= $match[1] . '=' . $match[2] . $match[3];
+                $text = $match[4];
+
+                $tmp = $this->getNormAttr($match[2]);
+                if (isset($tmp[0])) {
+                    $attrs[$match[1]] = $tmp;
+
+                    if ($this->strict) {
+                        // в теге не может быть вторичных атрибутов
+                        if (! $this->bbcodes[$tag]['other']) {
+                            $this->errors[] = [8, $tag];
+                            return null;
+                        }
+                        // этот атрибут отсутвтует в описании тега
+                        if (! isset($this->bbcodes[$tag]['attrs'][$match[1]])) {
+                            $this->errors[] = [10, $tag, $match[1]];
+                            return null;
+                        }
+                    }
+                }
+
+            } while ($match[3] !== ']');
+        }
+
+        if (empty($attrs)) {
+            // в теге должны быть атрибуты
+            if (! empty($this->bbcodes[$tag]['required'])
+                || ! isset($this->bbcodes[$tag]['attrs']['no attr'])
+            ) {
+                $this->errors[] = [6, $tag];
+                return null;
+            }
+        } else {
+            foreach ($this->bbcodes[$tag]['required'] as $key) {
+                // нет обязательного атрибута
+                if (! isset($attrs[$key])) {
+                    $this->errors[] = [13, $tag, $key];
+                    return null;
+                }
+            }
+        }
+
+        return [
+            'attrs' => $attrs,
+            'tag' => $tagText,
+            'text' => $text,
+        ];
+    }
+
+    /**
+     * Метод определяет указатель на родительский тег для текущего
+     *
+     * @param  string    $tag Имя тега
+     * @return int|false      false, если невозможно подобрать родителя
+     */
+    protected function findParent($tag)
+    {
+        if (false === $this->bbcodes[$tag]['self nesting']) {
+            $curId = $this->curId;
+
+            while (null !== $curId) {
+                // этот тег нельзя открыть внутри аналогичного
+                if ($this->data[$curId]['tag'] === $tag) {
+                    $this->errors[] = [12, $tag];
+                    return false;
+                }
+                $curId = $this->data[$curId]['parent'];
+            }
+        }
+
+        $curId = $this->curId;
+        $curTag = $this->data[$curId]['tag'];
+
+        while (null !== $curId) {
+            if (isset($this->bbcodes[$tag]['parents'][$this->bbcodes[$curTag]['type']])) {
+                return $curId;
+            } else if ($this->bbcodes[$tag]['type'] === 'inline'
+                       || false === $this->bbcodes[$curTag]['auto']
+            ) {
+                // тег не может быть открыт на этой позиции
+                $this->errors[] = [3, $tag, $this->data[$this->curId]['tag']];
+                return false;
+            }
+
+            $curId = $this->data[$curId]['parent'];
+            $curTag = $this->data[$curId]['tag'];
+        }
+
+        $this->errors[] = [3, $tag, $this->data[$this->curId]['tag']];
+        return false;
+    }
+
+    /**
+     * Метод проводит проверку значений атрибутов и(или) тела тега на соответствие правилам
+     *
+     * @param  string      $tag   Имя тега
+     * @param  array       $attrs Массив атрибутов
+     * @param  string      $text  Текст из которого выделяется тело тега
+     * @return array|false        false в случае ошибки
+     */
+    protected function validationTag($tag, $attrs, $text)
+    {
+        if (empty($attrs)) {
+            $attrs['no attr'] = null;
+        }
+
+        $body = null;
+        $end = null;
+        $tested = [];
+        $flag = false;
+        $bb = $this->bbcodes[$tag];
+
+        foreach ($attrs as $key => $val) {
+            // проверка формата атрибута
+            if (isset($bb['attrs'][$key]['format'])
+                && ! preg_match($bb['attrs'][$key]['format'], $val)
+            ) {
+                $this->errors[] = [9, $tag, $key];
+                return false;
+            }
+
+            // для рекурсивного тега тело не проверяется даже если есть правила
+            if (isset($bb['recursive'])) {
+                continue;
+            }
+
+            // тело тега
+            if (null === $body
+                && (isset($bb['attrs'][$key]['body format'])
+                    || isset($bb['attrs'][$key]['text only']))
+            ) {
+                $ptag = preg_quote($tag, '%');
+                $match = preg_split('%^([^\[]*(?:\[(?!/' . $ptag . '\])[^\[]*)*)(?:\[/' . $ptag . '\])?%i', $text, 2, PREG_SPLIT_DELIM_CAPTURE);
+
+                $body = $match[1];
+                $end = $match[2];
+            }
+
+            // для тега с 'text only' устанавливается флаг для возврата тела
+            if (isset($bb['attrs'][$key]['text only'])) {
+                $flag = true;
+            }
+
+            // проверка формата тела тега
+            if (isset($bb['attrs'][$key]['body format'])) {
+                if (isset($tested[$bb['attrs'][$key]['body format']])) {
+                    continue;
+                } else if (! preg_match($bb['attrs'][$key]['body format'], $body)) {
+                    $this->errors[] = [11, $tag];
+                    return false;
+                }
+
+                $tested[$bb['attrs'][$key]['body format']] = true;
+            }
+        }
+
+        unset($attrs['no attr']);
+
+        return [
+            'attrs' => $attrs,
+            'body' => $flag ? $body : null,
+            'end' => $end,
+        ];
+    }
+
+    /**
+     * Метод закрывает текущий тег
+     *
+     * @param  string $tag     Имя обрабатываемого тега
+     * @param  string $curText Текст до тега, который еще не был учтен
+     * @param  string $tagText Текст самого тега - [/tag]
+     * @return string          Пустая строка, если тег удалось закрыть
+     */
+    protected function closeTag($tag, $curText, $tagText) {
+        // ошибка одиночного тега
+        if (isset($this->bbcodes[$tag]['single'])) {
+            $this->errors[] = [5, $tag];
+            return $curText . $tagText;
+        }
+
+        $curId = $this->curId;
+        $curTag = $this->data[$curId]['tag'];
+
+        while ($curTag !== $tag && $curId > 0) {
+            if (false === $this->bbcodes[$curTag]['auto']) {
+                break;
+            }
+
+            $curId = $this->data[$curId]['parent'];
+            $curTag = $this->data[$curId]['tag'];
+        }
+
+        // ошибка закрытия тега
+        if ($curTag !== $tag) {
+            $this->errors[] = [4, $tag];
+            return $curText . $tagText;
+        }
+
+        $this->addTextNode($curText, $this->curId);
+
+        $this->curId = $this->data[$curId]['parent'];
+        return '';
+    }
+
+    /**
+     * Сброс состояния
+     *
+     * @param array $opts Ассоциативный массив опций
+     */
+    protected function reset(array $opts)
+    {
+        $this->defaultROOT();
+        $this->data = [];
+        $this->dataId = -1;
+        $this->curId = $this->addTagNode(
+            isset($opts['root']) && isset($this->bbcodes[$opts['root']])
+            ? $opts['root']
+            : 'ROOT'
+        );
+        $this->smOn = false;
+        $this->errors = [];
+        $this->strict = isset($opts['strict']) ? (bool) $opts['strict'] : false;
+    }
+
+    /**
+     * Метод строит дерево тегов из текста содержащего bb-коды
+     *
+     * @param  string   $text Обрабатываемый текст
+     * @param  array    $opts Ассоциативный массив опций
+     * @return Parserus $this
+     */
+    public function parse($text, $opts = [])
+    {
+        $this->reset($opts);
+        $curText = '';
+        $recCount = 0;
+
+        $text = str_replace("\r\n", "\n", $text);
+        $text = str_replace("\r", "\n", $text);
+
+        while (($match = preg_split('%(\[(/)?(' . ($recCount ? $recTag : '[a-z\*][a-z\d-]{0,10}') . ')((?(1)\]|[=\]\x20])))%i', $text, 2, PREG_SPLIT_DELIM_CAPTURE))
+               && isset($match[1])
+        ) {
+         /* $match[0] - текст до тега
+          * $match[1] - [ + (|/) + имя тега + (]| |=)
+          * $match[2] - (|/)
+          * $match[3] - имя тега
+          * $match[4] - тип атрибутов --> (]| |=)
+          * $match[5] - остаток текста до конца
+          */
+            $tagText = $match[1];
+            $curText .= $match[0];
+            $text = $match[5];
+            $tag = strtolower($match[3]);
+
+            if (! isset($this->bbcodes[$tag])) {
+                $curText .= $tagText;
+                continue;
+            }
+
+            if (! empty($match[2])) {
+                if ($recCount && --$recCount) {
+                    $curText .= $tagText;
+                } else {
+                    $curText = $this->closeTag($tag, $curText, $tagText);
+                }
+                continue;
+            }
+
+            $attrs = $this->parseAttrs($tag, $match[4], $text);
+
+            if (null === $attrs) {
+                $curText .= $tagText;
+                continue;
+            }
+
+            if (isset($attrs['tag'][0])) {
+                $tagText .= $attrs['tag'];
+                $text = $attrs['text'];
+            }
+
+            if ($recCount) {
+                ++$recCount;
+                $curText .= $tagText;
+                continue;
+            }
+
+            if (null !== $this->blackList && in_array($tag, $this->blackList)) {
+                $curText .= $tagText;
+                $this->errors[] = [1, $tag];
+                continue;
+            }
+
+            if (null !== $this->whiteList && ! in_array($tag, $this->whiteList)) {
+                $curText .= $tagText;
+                $this->errors[] = [2, $tag];
+                continue;
+            }
+
+            if (($parentId = $this->findParent($tag)) === false) {
+                $curText .= $tagText;
+                continue;
+            }
+
+            if (($attrs = $this->validationTag($tag, $attrs['attrs'], $text)) === false) {
+                $curText .= $tagText;
+                continue;
+            }
+
+            $curText = $this->addTextNode($curText, $this->curId);
+
+            $id = $this->addTagNode(
+                $tag,
+                $parentId,
+                $attrs['attrs'],
+                isset($attrs['body']) || isset($this->bbcodes[$tag]['text only'])
+            );
+
+            if (isset($attrs['body'])) {
+                $this->addTextNode($attrs['body'], $id);
+
+                $text = $attrs['end'];
+                $this->curId = $parentId;
+
+            } else if (isset($this->bbcodes[$tag]['single'])) {
+                $this->curId = $parentId;
+
+            } else {
+                $this->curId = $id;
+
+                if (isset($this->bbcodes[$tag]['recursive'])) {
+                    $recCount = 1;
+                    $recTag = preg_quote($tag, '%');
+                }
+            }
+        }
+
+        $this->addTextNode($curText . $text, $this->curId);
+        return $this;
+    }
+
+    /**
+     * Метод возвращает HTML построенный на основании дерева тегов
+     *
+     * @param  int    $id Указатель на текущий тег
+     * @return string
+     */
+    public function getHtml($id = 0)
+    {
+        if (isset($this->data[$id]['tag'])) {
+
+            $body = '';
+            foreach ($this->data[$id]['children'] as $cid) {
+                $body .= $this->getHtml($cid);
+            }
+
+            $bb = $this->bbcodes[$this->data[$id]['tag']];
+
+            if (null === $bb['handler']) {
+                return $body;
+            }
+
+            $attrs = [];
+            foreach ($this->data[$id]['attrs'] as $key => $val) {
+                if (isset($bb['attrs'][$key])) {
+                    $attrs[$key] = $this->e($val);
+                }
+            }
+
+            return $bb['handler']($body, $attrs, $this);
+        }
+
+        $pid = $this->data[$id]['parent'];
+        $bb = $this->bbcodes[$this->data[$pid]['tag']];
+
+        if (isset($bb['tags only'])) {
+            return '';
+        }
+
+        switch (2 * (end($this->data[$pid]['children']) === $id)
+                + ($this->data[$pid]['children'][0] === $id)
+        ) {
+            case 1:
+                $text = $this->e(preg_replace('%^\x20*\n%', '', $this->data[$id]['text']));
+                break;
+            case 2:
+                $text = $this->e(preg_replace('%\n\x20*$%D', '', $this->data[$id]['text']));
+                break;
+            case 3:
+                $text = $this->e(preg_replace('%^\x20*\n|\n\x20*$%D', '', $this->data[$id]['text']));
+                break;
+            default:
+                $text = $this->e($this->data[$id]['text']);
+                break;
+        }
+
+        if (empty($this->data[$pid]['text only'])
+            && $this->smOn
+            && isset($this->bbcodes[$this->smTag]['parents'][$bb['type']])
+            && ! isset($this->smBL[$this->data[$pid]['tag']])
+        ) {
+            $text = preg_replace_callback($this->smPattern, function($m) {
+                return str_replace(
+                    ['{url}', '{alt}'],
+                    [$this->e($this->smilies[$m[0]]), $this->e($m[0])],
+                    $this->smTpl
+                );
+            }, $text);
+        }
+
+        if (! isset($bb['pre'])) {
+            $text = str_replace($this->tSearch, $this->tRepl, $text);
+        }
+
+        return $text;
+    }
+
+    /**
+     * Метод возвращает текст с bb-кодами построенный на основании дерева тегов
+     *
+     * @param  int    $id Указатель на текущий тег
+     * @return string
+     */
+    public function getCode($id = 0)
+    {
+        if (isset($this->data[$id]['text'])) {
+            return $this->data[$id]['text'];
+        }
+
+        $body = '';
+        foreach ($this->data[$id]['children'] as $cid) {
+            $body .= $this->getCode($cid);
+        }
+
+        if ($id === 0) {
+            return $body;
+        }
+
+        $tag = $this->data[$id]['tag'];
+        $attrs = $this->data[$id]['attrs'];
+
+        $def = '';
+        $other = '';
+        $count = count($attrs);
+        foreach ($attrs as $attr => $val) {
+            $quote = '';
+            if ($count > 1 || strpbrk($val, ' \'"]')) {
+                $quote = '"';
+                if (false !== strpos($val, '"') && false === strpos($val, '\'')) {
+                    $quote = '\'';
+                }
+            }
+            if ($attr === 'Def') {
+                $def = '=' . $quote . $val . $quote;
+            } else {
+                $other .= ' ' . $attr . '=' . $quote . $val . $quote;
+            }
+        }
+
+        return '[' . $tag . $def . $other . ']' . (isset($this->bbcodes[$tag]['single']) ? '' : $body . '[/' . $tag .']');
+    }
+
+    /**
+     * Метод ищет в текстовых узлах ссылки и создает на их месте узлы с bb-кодами url
+     * Для уменьшения нагрузки использовать при сохранении, а не при выводе
+     *
+     * @return Parserus $this
+     */
+    public function detectUrls()
+    {
+        $pattern = '%\b(?<=\s|^)
+            (?>(?:ht|f)tps?://|www\.|ftp\.)
+            (?:[\p{L}\p{N}]+(?:[\p{L}\p{N}\-]*[\p{L}\p{N}])?\.)+
+            \p{L}[\p{L}\p{N}\-]*[\p{L}\p{N}]
+            (?::\d{1,5})?
+            (?:/
+                (?:[\p{L}\p{N};:@&=$_.+!*\'"(),\%/-]+)?
+                (?:\?[\p{L}\p{N};:@&=$_.+!*\'"(),\%-]+)?
+                (?:\#[\p{L}\p{N}-]+)?
+            )?%xu';
+
+        return $this->detect('url', $pattern, true);
+    }
+
+    /**
+     * Метод ищет в текстовых узлах совпадения с $pattern и создает на их месте узлы с bb-кодами $tag
+     *
+     * @param  string   $tag      Имя для создания bb-кода
+     * @param  string   $pattern  Регулярное выражение для поиска
+     * @param  bool     $textOnly Флаг. true, если содержимое созданного тега текстовое
+     * @return Parserus $this
+     */
+    protected function detect($tag, $pattern, $textOnly)
+    {
+        if (! isset($this->bbcodes[$tag])) {
+            return $this;
+        }
+
+        $error = null;
+        if (null !== $this->blackList && in_array($tag, $this->blackList)) {
+            $error = 1;
+        } else if (null !== $this->whiteList && ! in_array($tag, $this->whiteList)) {
+            $error = 2;
+        }
+
+        for ($id = $this->dataId; $id > 0; --$id) {
+            // не текстовый узел
+            if (! isset($this->data[$id]['text'])) {
+                continue;
+            }
+
+            $pid = $this->data[$id]['parent'];
+
+            // родитель может содержать только текст или не подходит по типу
+            if (isset($this->data[$pid]['text only']) ||
+                ! isset($this->bbcodes[$tag]['parents'][$this->bbcodes[$this->data[$pid]['tag']]['type']])
+            ) {
+                continue;
+            }
+
+            if (! preg_match_all($pattern, $this->data[$id]['text'], $matches, PREG_OFFSET_CAPTURE)) {
+                continue;
+            } else if ($error) {
+                $this->errors[] = [$error, $tag];
+                return $this;
+            }
+
+            $idx = array_search($id, $this->data[$pid]['children']);
+            $arrEnd = array_slice($this->data[$pid]['children'], $idx + 1);
+            $this->data[$pid]['children'] = array_slice($this->data[$pid]['children'], 0, $idx);
+
+            $pos = 0;
+
+            foreach ($matches[0] as $match) {
+                $this->addTextNode(substr($this->data[$id]['text'], $pos, $match[1] - $pos), $pid);
+
+                $new = $this->addTagNode($tag, $pid, [], $textOnly);
+                $this->addTextNode($match[0], $new);
+
+                $pos = $match[1] + strlen($match[0]);
+            }
+
+            $this->addTextNode($this->endStr($this->data[$id]['text'], $pos), $pid);
+            unset($this->data[$id]);
+
+            $this->data[$pid]['children'] = array_merge($this->data[$pid]['children'], $arrEnd);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Метод удаляет пустые теги из дерева
+     *
+     * @param  string $mask Маска символов, которые не учитываются при определении пустоты текстовых узлов
+     * @param  bool   $flag Если true, то при пустом дереве оно не будет очищено, а останется без изменений,
+     *                      но будет оставлена ошибка, которая отобразится в getErrors()
+     * @return bool         Если true, то дерево тегов пусто
+     */
+    public function stripEmptyTags($mask = '', $flag = false)
+    {
+        if ($flag) {
+            $data = $this->data;
+
+            if ($this->stripEmptyTags_($mask, 0)) {
+                $this->errors[] = [14];
+                $this->data = $data;
+                return true;
+            }
+            return false;
+
+        } else {
+            return $this->stripEmptyTags_($mask, 0);
+        }
+    }
+
+    /**
+     * Метод рекурсивно удаляет пустые теги из дерева
+     *
+     * @param  string $mask Маска символов, которые не учитываются при определении пустоты текстовых узлов
+     * @param  int    $id   Указатель на текущий тег
+     * @return bool         Если true, то тег/узел пустой
+     */
+    protected function stripEmptyTags_($mask, $id)
+    {
+        // текстовый узел
+        if (isset($this->data[$id]['text'])) {
+            if (isset($mask[0])) {
+                return trim($this->data[$id]['text'], $mask) === '';
+            }
+            return false;
+        }
+
+        // одиночный тег
+        if (isset($this->bbcodes[$this->data[$id]['tag']]['single'])) {
+            return false;
+        }
+
+        $res = true;
+        // перебор детей с удалением тегов
+        foreach ($this->data[$id]['children'] as $key => $cid) {
+            if ($this->stripEmptyTags_($mask, $cid)) {
+                if (isset($this->data[$cid]['tag'])) {
+                    unset($this->data[$id]['children'][$key]);
+                    unset($this->data[$cid]);
+                }
+            } else {
+               $res = false;
+            }
+        }
+
+        if ($res) {
+            foreach ($this->data[$id]['children'] as $cid) {
+                unset($this->data[$cid]);
+            }
+            $this->data[$id]['children'] = [];
+        }
+
+        return $res;
+    }
+
+    /**
+     * Метод возвращает массив ошибок
+     *
+     * @param  array $lang Массив строк шаблонов описания ошибок
+     * @return array
+     */
+    public function getErrors(array $lang = [])
+    {
+        $defLang = [
+            1 => 'Тег [%1$s] находится в черном списке',
+            2 => 'Тег [%1$s] отсутствует в белом списке',
+            3 => 'Тег [%1$s] нельзя открыть внутри тега [%2$s]',
+            4 => 'Не найден начальный тег для парного тега [/%1$s]',
+            5 => 'Найден парный тег [/%1$s] для одиночного тега [%1$s]',
+            6 => 'В теге [%1$s] отсутствуют атрибуты',
+            7 => 'Тег [%1$s=...] не может содержать первичный атрибут',
+            8 => 'Тег [%1$s ...] не может содержать вторичные атрибуты',
+            9 => 'Атрибут \'%2$s\' тега [%1$s] не соответствует шаблону',
+            10 => 'Тег [%1$s ...] содержит неизвестный вторичный атрибут \'%2$s\'',
+            11 => 'Тело тега [%1$s] не соответствует шаблону',
+            12 => 'Тег [%1$s] нельзя открыть внутри аналогичного тега',
+            13 => 'В теге [%1$s] отсутствует обязательный атрибут \'%2$s\'',
+            14 => 'Все теги пустые'
+        ];
+
+        $errors = [];
+
+        foreach ($this->errors as $args) {
+            $err = array_shift($args);
+
+            if (isset($lang[$err])) {
+                $text = $lang[$err];
+            } else if (isset($defLang[$err])) {
+                $text = $defLang[$err];
+            } else {
+                $text = 'Unknown error';
+            }
+
+            $errors[] = vsprintf($text, array_map([$this, 'e'], $args));
+        }
+
+        return $errors;
+    }
+
+    /**
+     * Метод преобразует специальные символы в HTML-сущности
+     *
+     * @param  string $text
+     * @return string
+     */
+    public function e($text)
+    {
+        return htmlspecialchars($text, $this->eFlags, 'UTF-8');
+    }
+
+    /**
+     * Метод возвращает окончание строки
+     *
+     * @param  string $str Текст
+     * @param  int    $pos Начальная позиция в байтах с которой идет возврат текста
+     * @return string
+     */
+    protected function endStr($str, $pos)
+    {
+        $s = substr($str, $pos);
+        return false === $s  ? '' : $s;
+    }
+}

+ 40 - 0
vendor/miovisman/parserus/README.md

@@ -0,0 +1,40 @@
+# Parserus
+
+[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
+
+BBCode parser.
+
+## Requirements
+
+* PHP 5.4.0
+
+## Installation
+
+Include `Parserus.php` or install [the composer package](https://packagist.org/packages/MioVisman/Parserus).
+
+## Example
+
+``` php
+$parser = new Parserus();
+
+echo $parser->addBBCode([
+    'tag' => 'b',
+    'handler' => function($body) {
+        return '<b>' . $body . '</b>';
+    }
+])->addBBcode([
+    'tag' => 'i',
+    'handler' => function($body) {
+        return '<i>' . $body . '</i>';
+    },
+])->parse("[i]Hello\n[b]World[/b]![/i]")
+->getHTML();
+
+#output: <i>Hello<br><b>World</b>!</i>
+```
+
+More examples in [the wiki](https://github.com/MioVisman/Parserus/wiki).
+
+## License
+
+This project is under MIT license. Please see the [license file](LICENSE) for details.

+ 21 - 0
vendor/miovisman/parserus/composer.json

@@ -0,0 +1,21 @@
+{
+    "name": "miovisman/parserus",
+    "description": "BBCode parser.",
+    "keywords": ["bbcode", "parser"],
+    "homepage": "https://github.com/MioVisman/Parserus",
+    "type": "library",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Visman",
+            "email": "mio.visman@yandex.ru",
+            "homepage": "https://github.com/MioVisman"
+        }
+    ],
+    "require": {
+        "php": ">=5.4.0"
+    },
+    "autoload": {
+        "psr-0": {"Parserus": ""}
+    }
+}

+ 20 - 0
vendor/miovisman/parserus/examples/_first.php

@@ -0,0 +1,20 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->addBBCode([
+    'tag' => 'b',
+    'handler' => function($body) {
+        return '<b>' . $body . '</b>';
+    }
+])->addBBcode([
+    'tag' => 'i',
+    'handler' => function($body) {
+        return '<i>' . $body . '</i>';
+    },
+])->parse("[i]Hello\n[b]World[/b]![/i]")
+->getHTML();
+
+#output: <i>Hello<br><b>World</b>!</i>

+ 20 - 0
vendor/miovisman/parserus/examples/_second.php

@@ -0,0 +1,20 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus(ENT_XHTML);
+
+echo $parser->addBBCode([
+    'tag' => 'b',
+    'handler' => function($body) {
+        return '<b>' . $body . '</b>';
+    }
+])->addBBcode([
+    'tag' => 'i',
+    'handler' => function($body) {
+        return '<i>' . $body . '</i>';
+    },
+])->parse("[i]Hello\n[b]World[/b]![/i]")
+->getHTML();
+
+#output: <i>Hello<br /><b>World</b>!</i>

+ 20 - 0
vendor/miovisman/parserus/examples/_third.php

@@ -0,0 +1,20 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->addBBCode([
+    'tag' => 'b',
+    'handler' => function($body) {
+        return '<b>' . $body . '</b>';
+    }
+])->addBBcode([
+    'tag' => 'i',
+    'handler' => function($body) {
+        return '<i>' . $body . '</i>';
+    },
+])->parse("[i]\nHello\n[b]\nWorld!")
+->getHTML();
+
+#output: <i>Hello<br><b>World!</b></i>

+ 50 - 0
vendor/miovisman/parserus/examples/attr.php

@@ -0,0 +1,50 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->addBBCode([
+    'tag' => 'after',
+    'type' => 'block',
+    'single' => true,
+    'attrs' => [
+        'Def' => [
+           'format' => '%^\d+$%',
+        ],
+    ],
+    'handler' => function($body, $attrs, $parser) {
+        $lang = $parser->attr('lang');
+        $arr = array();
+        $sec = $attrs['Def'] % 60;
+        $min = ($attrs['Def'] / 60) % 60;
+        $hours = ($attrs['Def'] / 3600) % 24;
+        $days = (int) ($attrs['Def'] / 86400);
+        if ($days > 0) {
+            $arr[] = $days . $lang['After time d'];
+        }
+        if ($hours > 0) {
+            $arr[] = $hours . $lang['After time H'];
+        }
+        if ($min > 0) {
+            $arr[] = (($min < 10) ? '0' . $min : $min) . $lang['After time i'];
+        }
+        if ($sec > 0) {
+            $arr[] = (($sec < 10) ? '0' . $sec : $sec) . $lang['After time s'];
+        }
+
+        $attr = $lang['After time'] . ' ' . implode(' ', $arr);
+
+        return '<span style="color: #808080"><em>' . $attr . ':</em></span><br>';
+     },
+])->setAttr('lang', [
+    'After time'   => 'Added later',
+    'After time s' => ' s',
+    'After time i' => ' min',
+    'After time H' => ' h',
+    'After time d' => ' d',
+])->parse('[after=10123]')
+  ->getHTML();
+
+
+#output: <span style="color: #808080"><em>Added later 2 h 48 min 43 s:</em></span><br>

File diff suppressed because it is too large
+ 554 - 0
vendor/miovisman/parserus/examples/bbcodes_test.php


+ 27 - 0
vendor/miovisman/parserus/examples/detectUrls.php

@@ -0,0 +1,27 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->setBBCodes([
+    ['tag' => 'url',
+     'type' => 'url',
+     'parents' => ['inline', 'block'],
+     'attrs' => [
+         'Def' => [
+             'format' => '%^[^\x00-\x1f]+$%',
+         ],
+         'no attr' => [
+             'body format' => '%^[^\x00-\x1f]+$%D',
+         ],
+     ],
+     'handler' => function($body, $attrs, $parser) {
+#...
+     },
+    ],
+])->parse('Hello www.example.com World!')
+  ->detectUrls()
+  ->getCode();
+
+#output: Hello [url]www.example.com[/url] World!

+ 63 - 0
vendor/miovisman/parserus/examples/detectUrls2.php

@@ -0,0 +1,63 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->setBBCodes([
+    ['tag' => 'url',
+     'type' => 'url',
+     'parents' => ['inline', 'block'],
+     'attrs' => [
+         'Def' => [
+             'format' => '%^[^\x00-\x1f]+$%',
+         ],
+         'no attr' => [
+             'body format' => '%^[^\x00-\x1f]+$%D',
+         ],
+     ],
+     'handler' => function($body, $attrs, $parser) {
+#...
+     },
+    ],
+    ['tag' => 'h',
+     'type' => 'h',
+     'handler' => function($body, $attrs, $parser) {
+#...
+     },
+    ],
+])->parse('www.example.com/link1[h]Hello www.example.com/link2 World![/h]www.example.com/link3')
+  ->detectUrls()
+  ->getCode();
+
+#output: [url]www.example.com/link1[/url][h]Hello www.example.com/link2 World![/h][url]www.example.com/link3[/url]
+
+echo "\n\n";
+
+echo $parser->setBlackList(['url'])
+  ->setWhiteList()
+  ->parse('www.example.com/link1[h]Hello www.example.com/link2 World![/h]www.example.com/link3')
+  ->detectUrls()
+  ->getCode();
+
+#output: www.example.com/link1[h]Hello www.example.com/link2 World![/h]www.example.com/link3
+
+var_dump($parser->getErrors());
+
+#output: array (size=1)
+#output:   0 => string 'Тег [url] находится в черном списке' (length=60)
+
+echo "\n\n";
+
+echo $parser->setBlackList()
+  ->setWhiteList(['h'])
+  ->parse('www.example.com/link1[h]Hello www.example.com/link2 World![/h]www.example.com/link3')
+  ->detectUrls()
+  ->getCode();
+
+#output: www.example.com/link1[h]Hello www.example.com/link2 World![/h]www.example.com/link3
+
+var_dump($parser->getErrors());
+
+#output: array (size=1)
+#output:   0 => string 'Тег [url] отсутствует в белом списке' (length=62)

+ 9 - 0
vendor/miovisman/parserus/examples/e.php

@@ -0,0 +1,9 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->e("<'abcde'>");
+
+#output: &lt;&apos;abcde&apos;&gt;

+ 126 - 0
vendor/miovisman/parserus/examples/getCode.php

@@ -0,0 +1,126 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->setBBCodes([
+    ['tag' => 'table',
+     'type' => 'table',
+     'tags only' => true,
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+         'align' => true,
+         'background' => true,
+         'bgcolor' => true,
+         'border' => true,
+         'bordercolor' => true,
+         'cellpadding' => true,
+         'cellspacing' => true,
+         'frame' => true,
+         'rules' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<table' . $attr . '>' . $body . '</table>';
+     },
+    ],
+    ['tag' => 'tr',
+     'type' => 'tr',
+     'parents' => ['table', 't'],
+     'tags only' => true,
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<tr' . $attr . '>' . $body . '</tr>';
+     },
+    ],
+    ['tag' => 'th',
+     'type' => 'block',
+     'parents' => ['tr'],
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+         'colspan' => true,
+         'rowspan' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<th' . $attr . '>' . $body . '</th>';
+     },
+    ],
+    ['tag' => 'td',
+     'type' => 'block',
+     'parents' => ['tr'],
+     'self nesting' => true,
+     'attrs' => [
+         'no attr' => true,
+         'style' => true,
+         'colspan' => true,
+         'rowspan' => true,
+     ],
+     'handler' => function($body, $attrs) {
+         $attr = '';
+         foreach ($attrs as $key => $val) {
+             $attr .= ' ' . $key . '="' . $val . '"';
+         }
+         return '<td' . $attr . '>' . $body . '</td>';
+     },
+    ],
+])->parse('
+[table align=right border=1 bordercolor=#ccc      cellpadding=5 cellspacing=0 style="border-collapse:collapse; width:500px"]
+		[tr]
+			[th style="width:50%"]Position[/th]
+			[th style=width:50%]Astronaut[/th]
+		[/tr]
+		[tr]
+			[td]Commander[/td]
+			[td]Neil A. Armstrong[/td]
+		[/tr]
+		[tr]
+			[td]Command Module Pilot[/td]
+			[td]Michael Collins[/td]
+		[/tr]
+		[tr]
+			[td]Lunar Module Pilot[/td]
+			[td]Edwin "Buzz" E. Aldrin, Jr.[/td]
+		[/tr]
+[/table]
+')->getCode();
+
+#output:
+#[table align="right" border="1" bordercolor="#ccc" cellpadding="5" cellspacing="0" style="border-collapse:collapse; width:500px"]
+#		[tr]
+#			[th style=width:50%]Position[/th]
+#			[th style=width:50%]Astronaut[/th]
+#		[/tr]
+#		[tr]
+#			[td]Commander[/td]
+#			[td]Neil A. Armstrong[/td]
+#		[/tr]
+#		[tr]
+#			[td]Command Module Pilot[/td]
+#			[td]Michael Collins[/td]
+#		[/tr]
+#		[tr]
+#			[td]Lunar Module Pilot[/td]
+#			[td]Edwin "Buzz" E. Aldrin, Jr.[/td]
+#		[/tr]
+#[/table]
+#

+ 41 - 0
vendor/miovisman/parserus/examples/getErrors.php

@@ -0,0 +1,41 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+$parser->addBBCode([
+    'tag' => 'b',
+    'handler' => function($body) {
+        return '<b>' . $body . '</b>';
+    }
+])->addBBcode([
+    'tag' => 'i',
+    'handler' => function($body) {
+        return '<i>' . $body . '</i>';
+    },
+]);
+
+$parser->parse("[i][b] [/b][/i]")->stripEmptyTags(" \n", true);
+
+$err = [
+    1 => '[%1$s] is in the black list',
+    2 => '[%1$s] is absent in the white list',
+    3 => '[%1$s] can\'t be opened in the [%2$s]',
+    4 => '[/%1$s] was found without a matching [%1$s]',
+    5 => '[/%1$s] is found for single [%1$s]',
+    6 => 'There are no attributes in [%1$s]',
+    7 => 'Primary attribute is forbidden in [%1$s=...]',
+    8 => 'Secondary attributes are forbidden in [%1$s ...]',
+    9 => 'The attribute \'%2$s\' doesn\'t correspond to a template in the [%1$s]',
+    10 => '[%1$s ...] contains unknown secondary attribute \'%2$s\'',
+    11 => 'The body of [%1$s] doesn\'t correspond to a template',
+    12 => '[%1$s] was opened within itself, this is not allowed',
+    13 => 'In the [%1$s] is absent mandatory attribute \'%2$s\'',
+    14 => 'All tags are empty'
+];
+
+var_dump($parser->getErrors($err));
+
+#output: array (size=1)
+#  0 => string 'All tags are empty' (length=18)

+ 34 - 0
vendor/miovisman/parserus/examples/smilies.php

@@ -0,0 +1,34 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+echo $parser->setSmilies([
+    ':)' => 'http://example.com/smilies/smile.png',
+    ';)' => 'http://example.com/smilies/wink.png',
+])->addBBCode([
+    'tag' => 'img',
+    'type' => 'img',
+    'parents' => ['inline', 'block', 'url'],
+    'text only' => true,
+    'attrs' => [
+        'Def' => [
+            'body format' => '%^(?:(?:ht|f)tps?://[^\x00-\x1f\s<"]+|data:image/[a-z]+;base64,(?:[a-zA-Z\d/\+\=]+))$%D'
+        ],
+        'no attr' => [
+            'body format' => '%^(?:(?:ht|f)tps?://[^\x00-\x1f\s<"]+|data:image/[a-z]+;base64,(?:[a-zA-Z\d/\+\=]+))$%D'
+        ],
+    ],
+    'handler' => function($body, $attrs, $parser) {
+        if (! isset($attrs['Def'])) {
+            $attrs['Def'] = (substr($body, 0, 11) === 'data:image/') ? 'base64' : basename($body);
+        }
+        return '<img src="' . $body . '" alt="' . $attrs['Def'] . '">';
+    },
+])->setSmTpl('<img src="{url}" alt="{alt}">')
+  ->parse(":)\n;)")
+  ->detectSmilies()
+  ->getHTML();
+
+#output: <img src="http://example.com/smilies/smile.png" alt=":)"><br><img src="http://example.com/smilies/wink.png" alt=";)">

+ 61 - 0
vendor/miovisman/parserus/examples/stripEmptyTags.php

@@ -0,0 +1,61 @@
+<?php
+
+include '../Parserus.php';
+
+$parser = new Parserus();
+
+$parser->addBBCode([
+    'tag' => 'b',
+    'handler' => function($body) {
+        return '<b>' . $body . '</b>';
+    }
+])->addBBcode([
+    'tag' => 'i',
+    'handler' => function($body) {
+        return '<i>' . $body . '</i>';
+    },
+]);
+
+# №1
+
+var_dump($parser->parse("[i][b] [/b][/i]")->stripEmptyTags());
+
+#output: boolean false
+
+echo $parser->getCode();
+
+#output: [i][b] [/b][/i]
+
+echo "\n\n";
+
+# №2
+
+var_dump($parser->parse("[i][b] [/b][/i]")->stripEmptyTags(" \n", true));
+
+#output: boolean true
+
+echo $parser->getCode();
+
+#output: [i][b] [/b][/i]
+
+var_dump($parser->getErrors());
+
+#output: array (size=1)
+#  0 => string 'Все теги пустые' (length=28)
+
+echo "\n\n";
+
+# №3
+
+var_dump($parser->parse("[i][b] [/b][/i]")->stripEmptyTags(" \n"));
+
+#output: boolean true
+
+echo $parser->getCode();
+
+#output:
+
+var_dump($parser->getErrors());
+
+#output: array (size=1)
+#  empty

Some files were not shown because too many files changed in this diff